diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 5857ae5c..c51ce46c 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 48 active rows (AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
+## 2. Adaptation (AD) — 47 active rows (AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
visible-cell availability, full-catalog containment-root validation, and the
@@ -115,7 +115,7 @@ readiness/requeue adaptation. See
| AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) |
| AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks |
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
-| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 2).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, one public generation-gated observe/retry/exact-ack seam, and the retail collision-table/report-result state needed by SetPosition. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining authored-mover, rebucketing, prefix-quiescence, body-publication, and route-cutover prerequisites land atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owners remain dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 |
+| ~~AD-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** The legacy recoverable outdoor demote (`Resolve`'s indoor-claim safety net) and the outdoor-restore `max(terrainZ, z)` lift this row described were `PhysicsEngine.Resolve`'s own body — deleted outright with the exhaustive C5a caller census proving zero production callers (every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState`). The divergent mechanism is unreachable from production because it no longer exists. | `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged; only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 |
@@ -159,7 +159,7 @@ readiness/requeue adaptation. See
---
-## 3. Documented approximation (AP) — 102 active rows (AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 101 active rows (AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -173,7 +173,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-144 | **Filed 2026-08-05 (C4 route 3, round-3 review R7). Register discipline finding, not an implementer's disposition** — CLAUDE.md's register rule binds regardless of whether the gap has a live symptom yet. `RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal`'s teleport-arrival movement-event send gates on `!RuntimeCharacterState.UsePositionFromServer` — retail's `CommandInterpreter::UsePositionFromServer` @0x006B3B40, which is `autonomy_level != 2`. But the retail function that ACTUALLY gates this send is a different one: `CommandInterpreter::SendMovementEvent` @0x006B4680 (the `PlayerTeleported` tail-jump), which gates on `autonomy_level != 0` — the LOOSER test, excluding only level 0, satisfied by BOTH level 1 and level 2. acdream's gate reuses the STRICTER `UsePositionFromServer` test (excluding two of the three levels, 0 AND 1), built from the wrong retail function, so it sends only at level 2 and wrongly suppresses at level 1. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`ReconcileAndAcknowledgePortal`, the `!_usePositionFromServer()` guard around `TrySendMovement`); `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`UsePositionFromServer`, `AutonomyLevel`) | The two gates agree at level 0 (both suppress) and level 2 (both send); they diverge only at level 1. `RuntimeCharacterState.TrySetAutonomyLevel` has zero production callers today, so no live code path can ever reach `AutonomyLevel == 1` — the divergence is filed for completeness, not because it is currently reachable. | The instant a future feature calls `TrySetAutonomyLevel(1)` (a partial-autonomy mode, if one is ever built), a portal-arrival movement-event ACE expects to receive at level 1 is silently dropped, until this row's fix threads the raw `AutonomyLevel` through the constructor (touching both host compositions) and gates on `!= 0` directly instead of reusing `UsePositionFromServer`. | `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (`autonomy_level != 2`); `CommandInterpreter::SendMovementEvent` @0x006B4680 (`autonomy_level != 0`, the `PlayerTeleported` tail-jump call site) |
| AP-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.App/World/LiveEntityRuntime.cs:935-938` (the landblock-preserve branch); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) |
| ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from |
-| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 |
+| ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 |
| ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 |
| ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 |
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 74042142..783eaf35 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -875,7 +875,10 @@ public static class CellTransit
// treats the world-frame sphere coordinates as if they were block-local and
// marches the cell one block per tick until lbX or lbY underflows to 0x00
// (the "lbX=0" outbound wire that ACE rejects). "frame not yet authoritative
- // → preserve verbatim" mirrors the NO-LANDBLOCK contract in PhysicsEngine.Resolve.
+ // → preserve verbatim" is the same no-landblock-match invariant canonical
+ // PhysicsEngine.SetPosition's AdjustSetPosition/DeferredCell path holds today
+ // (C5a, 2026-08-05: cite by symbol — the legacy PhysicsEngine.Resolve this
+ // note used to name is deleted, zero production callers).
// Indoor seeds are NOT guarded here because blockOrigin is only consumed by the
// outdoor pick path (outdoorPickAllowed=false for indoor seeds); returning early
// for indoor seeds would break all interior cell-set builds (regression).
@@ -1055,9 +1058,15 @@ public static class CellTransit
// outdoor column, stranding the player outdoor-classified deep inside
// the house (outdoor→indoor promotion is portal-adjacent-only, retail-
// identical) → the outdoor flood rendered the interior transparent.
- // The hatch's actual target — poisoned (cell, position) SAVES — is
- // handled at the SNAP by PhysicsEngine.Resolve's AdjustPosition
- // validation since #107/#111; mid-session farness cannot arise (the
+ // The hatch's actual target — poisoned (cell, position) SAVES — was
+ // handled at the SNAP by PhysicsEngine.AdjustPosition validation since
+ // #107/#111 (C5a, 2026-08-05: cite by symbol — the legacy
+ // PhysicsEngine.Resolve player-snap caller this note used to name is
+ // deleted, zero production callers; AdjustPosition survives, now
+ // reached in production only from PhysicsCameraCollisionProbe's
+ // camera-collision cell resolve, and canonical PhysicsEngine.SetPosition's
+ // AdjustSetPosition performs the equivalent validation for live
+ // placement); mid-session farness cannot arise (the
// sphere moves continuously, and real building exits flow through
// exterior portals → outside cells enter the candidate array → the
// normal outdoorResult path above demotes there, retail-faithfully).
diff --git a/src/AcDream.Core/Physics/Motion/ConstraintManager.cs b/src/AcDream.Core/Physics/Motion/ConstraintManager.cs
index 9d2c85d8..a5270734 100644
--- a/src/AcDream.Core/Physics/Motion/ConstraintManager.cs
+++ b/src/AcDream.Core/Physics/Motion/ConstraintManager.cs
@@ -22,9 +22,12 @@ namespace AcDream.Core.Physics.Motion;
/// equivalents call at every accepted-position
/// seam: LiveEntityNetworkUpdateController for remotes (anchored to
/// the object's own position, right after the hard-teleport branch returns)
-/// and PlayerMovementController.SetPosition/BlipPosition for
-/// the local player (anchored to the received position). See
-/// docs/research/2026-07-30-constraint-leash-constants.md.
+/// and PlayerMovementController.RearmConstraintLeashAtCurrentPosition
+/// (called from the teleport-hook rearm inside SetPositionCore and
+/// from ArmConstraintLeashAtCommittedPlacement) for the local player
+/// (anchored to the received position; C5a, 2026-08-05 — cite by symbol,
+/// the old SetPosition/BlipPosition pair this note named is
+/// gone). See docs/research/2026-07-30-constraint-leash-constants.md.
///
public sealed class ConstraintManager
{
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 37cd20f7..3b8567f1 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -439,9 +439,12 @@ public sealed class PhysicsBody
/// Campaign P P5 (2026-07-30) armed the leash at every current
/// inbound-position acceptance seam
/// (LiveEntityNetworkUpdateController for remotes,
- /// PlayerMovementController.SetPosition/BlipPosition for the
- /// local player — see docs/research/2026-07-30-constraint-leash-constants.md
- /// §2/§3). only holds a
+ /// PlayerMovementController.RearmConstraintLeashAtCurrentPosition
+ /// for the local player — see
+ /// docs/research/2026-07-30-constraint-leash-constants.md
+ /// §2/§3; C5a, 2026-08-05 — cite by symbol, the old
+ /// SetPosition/BlipPosition pair this note named is gone).
+ /// only holds a
/// reference (no host), so this property stays a
/// plain settable bool; the per-tick pump that already runs
/// PositionManager.AdjustOffset (PlayerMovementController.Update,
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 9fc0c335..60609609 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -21,10 +21,15 @@ internal readonly record struct TerrainWalkableSample(
///
///
/// Landblocks are registered via with their
-/// terrain, indoor cells, and world-space offsets.
-/// takes a current position, the entity's current cell ID, a movement delta,
-/// and a step-up height limit; it returns the validated new position, the
-/// updated cell ID, and whether the entity is standing on a surface.
+/// terrain, indoor cells, and world-space offsets.
+/// takes a current position, a target
+/// position, the entity's current cell ID, and the mover's sphere/step
+/// parameters; it returns the validated new position, the updated cell ID,
+/// and whether the entity is standing on a surface. C5a (2026-08-05): the
+/// legacy zero-delta Resolve snap this paragraph used to cite is
+/// gone — canonical placement now flows through SetPosition (Core)
+/// / PreparePositionForCommit + ArmConstraintLeashAtCommittedPlacement
+/// (Runtime), both of which settle through this same sphere-sweep resolver.
///
///
public sealed class PhysicsEngine
@@ -1220,12 +1225,17 @@ public sealed class PhysicsEngine
/// SmartBox::update_viewer calls this to seat the camera sweep's start
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
/// at the sought eye (:280078). The player snap path
- /// (SetPositionInternal :283908 → our ) calls it to
- /// validate the server-restored (cell, position) pair before any physics runs —
- /// the #107 indoor-login wedge was this validation missing: a poisoned save
- /// (cell id from one building, position inside another) was trusted verbatim,
- /// the player stood fake-grounded with no walkable floor, and the first movement
- /// demoted them outdoor mid-building → 2.4 m fall under the cottage floor.
+ /// (SetPositionInternal :283908) originally called it through the
+ /// legacy Resolve wrapper (deleted C5a, zero production callers)
+ /// to validate the server-restored (cell, position) pair before any
+ /// physics runs; the sole production caller today is
+ /// PhysicsCameraCollisionProbe (camera collision cell resolve) —
+ /// the #107 indoor-login wedge this method fixed was the validation
+ /// missing from the (now-deleted) player snap path:
+ /// a poisoned save (cell id from one building, position inside another)
+ /// was trusted verbatim, the player stood fake-grounded with no walkable
+ /// floor, and the first movement demoted them outdoor mid-building →
+ /// 2.4 m fall under the cottage floor.
///
///
/// #107 (2026-06-10) completed the previously-deferred indoor
@@ -1758,30 +1768,6 @@ public sealed class PhysicsEngine
return inside;
}
- ///
- /// #107: does any loaded landblock carry a for
- /// this cell id? Distinguishes "partially hydrated" (floor data present,
- /// struct pending — the legacy floor-snap can ground the claim) from
- /// "completely unknown" (the Resolve safety net demotes loudly).
- ///
- private bool HasCellSurface(uint cellId)
- {
- // Masked low-word compare (house norm in this file): production
- // CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test
- // fixtures bare low words. A zero-prefix (bare, pre-#106 convention)
- // claim matches any loaded landblock by low word — the legacy Resolve
- // body below treats bare claims the same way.
- uint low = cellId & 0xFFFFu;
- uint prefix = cellId & 0xFFFF0000u;
- foreach (var kvp in _landblocks)
- {
- if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue;
- foreach (var cell in kvp.Value.Cells)
- if ((cell.CellId & 0xFFFFu) == low) return true;
- }
- return false;
- }
-
///
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
/// server-claimed spawn cell is ready for to
@@ -1799,9 +1785,13 @@ public sealed class PhysicsEngine
/// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
/// first movement → transparent interior). Claims that can NEVER hydrate
/// (id outside the landblock's NumCells range) are now filtered by the
- /// caller against the dat, and carries a loud
- /// outdoor-demote safety net for any unhydrated indoor claim that still
- /// gets through.
+ /// caller against the dat. C5a (2026-08-05): the loud outdoor-demote
+ /// safety net this paragraph used to cite lived in the legacy
+ /// Resolve/HasCellSurface pair, deleted with zero production
+ /// callers — canonical SetPosition has no equivalent unhydrated-
+ /// claim demote, so a claim that fails this gate simply stays un-adjusted
+ /// until the streaming worker catches up, the same as any other caller
+ /// of that gets found = false.
///
///
public bool IsSpawnCellReady(uint cellId)
@@ -1848,357 +1838,16 @@ public sealed class PhysicsEngine
return (seedCellId, false);
}
- ///
- /// Resolve an entity's movement from by
- /// applying (XY only) and computing the correct Z
- /// from the terrain or indoor cell floor beneath the candidate position.
- ///
- ///
- /// Step-height enforcement rejects horizontal movement when the upward Z
- /// change exceeds . Downhill movement is
- /// always accepted. Returns false
- /// when no loaded landblock covers the candidate position.
- ///
- ///
- public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
- {
- // #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892)
- // step 1 — AdjustPosition (:283908) validates/corrects the claimed cell
- // from the position BEFORE any physics runs. This legacy Resolve is the
- // player snap path (login entry + teleport arrival — the SetPosition
- // shaped calls); both hand it a server-restored (cell, position) pair
- // that can be poisoned (the #107 capture: cell id from one building,
- // position inside another, 55 m apart). Retail validates at the foot-
- // sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903);
- // the player's foot sphere is radius 0.48 m centred 0.48 m above the
- // feet (PlayerMovementController body — capture input.sphereRadius).
- const float FootSphereCenterLift = 0.48f;
- var (adjustedCellId, adjustedFound) = AdjustPosition(
- cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
- if (adjustedFound && adjustedCellId != cellId)
- {
- Console.WriteLine(System.FormattableString.Invariant(
- $"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)"));
- cellId = adjustedCellId;
- }
- else if (!adjustedFound
- && (cellId & 0xFFFFu) >= 0x0100u
- && DataCache?.GetCellStruct(cellId) is null
- && !HasCellSurface(cellId))
- {
- // #107 safety net (2026-06-10 gate-run regression): an indoor claim
- // the engine knows NOTHING about (no cell struct AND no CellSurface
- // floor data) cannot be validated or grounded — committing it raw
- // reproduces the fake-grounded wedge. Retail goes lost-cell here
- // (GotoLostCell, :283418); our recoverable equivalent is the
- // outdoor landcell under the point (documented divergence — we have
- // no lost-cell machinery). When only the struct is missing but the
- // CellSurface floor exists (partial hydration), the legacy indoor
- // floor-snap below handles the claim — don't demote. The auto-entry
- // hold should make this unreachable in practice; if the line fires,
- // the hold has a gap.
- var (outdoorCellId, outdoorFound) = AdjustPosition(
- (cellId & 0xFFFF0000u) | 0x0001u,
- currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
- if (outdoorFound)
- {
- Console.WriteLine(System.FormattableString.Invariant(
- $"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)"));
- cellId = outdoorCellId;
- }
- }
-
- var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
-
- // #111 apparatus: one [snap] line per Resolve call (entry + teleport
- // arrival only — low volume, permanent). The gate-3/4/5 runs committed
- // ACE's restored pair VERBATIM through this method while every read
- // path should have changed Z or cell — this line answers which branch
- // actually ran. Remove or demote to env-gate once #111 closes.
- bool snapDiag = (delta.X == 0f && delta.Y == 0f);
-
- // Find the landblock this candidate position falls in.
- // #106 follow-up (2026-06-09): capture its high-16 prefix — every
- // computed cell id below is returned FULL (lbPrefix | low). The old
- // bare-low-word returns wedged the membership chain whenever a caller
- // committed them (the teleport-arrival snap wrote 0x0000013F: an
- // unresolvable indoor id → no wall BSP, #98 gate reads "indoor
- // primary" and kills the outdoor object sweep → no collision at all).
- LandblockPhysics? physics = null;
- uint lbPrefix = 0u;
- foreach (var kvp in _landblocks)
- {
- var lb = kvp.Value;
- float localX = candidatePos.X - lb.WorldOffsetX;
- float localY = candidatePos.Y - lb.WorldOffsetY;
- if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
- {
- physics = lb;
- lbPrefix = kvp.Key & 0xFFFF0000u;
- break;
- }
- }
-
- if (physics is null)
- {
- if (snapDiag && DiagnosticLog is { } noLandblockLog)
- noLandblockLog(System.FormattableString.Invariant(
- $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) branch=NO-LANDBLOCK (lbs={_landblocks.Count}) -> verbatim"));
- return new ResolveResult(candidatePos, cellId, IsOnGround: false);
- }
-
- float localCandX = candidatePos.X - physics.WorldOffsetX;
- float localCandY = candidatePos.Y - physics.WorldOffsetY;
-
- // #111 (2026-06-10): a VALIDATED indoor claim is AUTHORITATIVE for the
- // cell — retail SetPositionInternal commits the AdjustPosition cell and
- // only settles Z (CheckPositionInternal → find_valid_position, :283426);
- // it never re-picks the cell from floor geometry. The legacy bestCell
- // floor-pick below scans EVERY CellSurface in the landblock (123 at
- // Holtburg) and breaks same-height ties by iteration order — on a live
- // login it clobbered ACE's clean, validated claim 0xA9B40171 with
- // 0xA9B4013F (issue111-snap1.log), putting the player in a wrong cell
- // → outdoor demote on first movement → transparent interior (#111).
- // Snap shape only (zero delta): ground Z onto the validated claim's own
- // floor when it has one under this XY; cells without their own floor
- // surface here (thresholds, stair lips) fall through to the legacy path.
- if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u)
- {
- // Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥
- // PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the
- // soup includes ceiling/roof TOP faces whose first-hit (99.475
- // over 0x171's 94.0 floor, issue111-verify2.log) and even
- // nearest-to-reference (the poisoned reference SAT on the ceiling
- // face, issue111-verify3.log) selections both land on non-floors.
- // The walkable set contains only real floors (retail
- // BSPTREE::find_walkable's polygon filter).
- float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z);
- if (claimFloorZ is not null)
- {
- if (DiagnosticLog is { } validatedLog)
- {
- validatedLog(System.FormattableString.Invariant(
- $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}"));
- }
- // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id,
- // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning
- // resident landblocks for one whose [0,192) local bounds contain
- // the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE
- // (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon
- // landblock fails the localY>=0 bounds test, so the loop matches a
- // neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping
- // the validated claim 0x00070143 -> 0xA9B30143. The client then
- // mis-resolves the player into the wrong landblock and spams ACE with
- // rejected moves. The validated claim's prefix is AUTHORITATIVE; a
- // position falling in a neighbouring resident landblock must not
- // re-stamp it. Byte-identical for the login case (the position lies in
- // the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000);
- // diverges only — and correctly — in the far-teleport dungeon case.
- return new ResolveResult(
- new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value),
- cellId,
- IsOnGround: true);
- }
- }
-
- // Check if the candidate position falls on any indoor cell floor.
- // Pick the cell whose floor Z is closest to the entity's current Z.
- CellSurface? bestCell = null;
- float? bestCellZ = null;
- float bestZDist = float.MaxValue;
-
- foreach (var cell in physics.Cells)
- {
- float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
- if (floorZ is not null)
- {
- float dist = MathF.Abs(floorZ.Value - currentPos.Z);
- if (dist < bestZDist)
- {
- bestCell = cell;
- bestCellZ = floorZ;
- bestZDist = dist;
- }
- }
- }
-
- // Determine target surface Z and cell.
- float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
- float targetZ;
- uint targetCellId;
-
- // Only the low 16 bits of cellId carry the cell index. Outdoor
- // cells are 0x0001–0x0040; indoor (EnvCell) cells are 0x0100+.
- // The full 32-bit cellId includes the landblock prefix in the
- // high 16 bits (e.g., 0xA9B40001), so we MUST mask before
- // comparing. Without the mask, every cell looks "indoor" because
- // 0xA9B40001 >= 0x0100 → the engine always takes the "stay
- // indoors" path and snaps Z to an EnvCell floor 28m below.
- bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100;
-
- if (currentlyIndoor)
- {
- // Check whether the player crosses a portal belonging to the current cell.
- uint currentCellIndex = cellId & 0xFFFFu;
- PortalPlane? crossedPortal = null;
- foreach (var portal in physics.Portals)
- {
- // Only portals owned by the current cell are relevant when indoors.
- if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue;
- if (portal.IsCrossing(currentPos, candidatePos))
- {
- crossedPortal = portal;
- break;
- }
- }
-
- if (crossedPortal is not null)
- {
- if (crossedPortal.Value.TargetCellId == 0xFFFFu)
- {
- // Indoor → Outdoor exit.
- targetZ = terrainZ;
- targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
- }
- else
- {
- // Indoor → Indoor (room to room).
- uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu;
- CellSurface? nextCell = null;
- foreach (var c in physics.Cells)
- {
- if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; }
- }
- float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
- targetZ = nextFloorZ ?? terrainZ;
- targetCellId = nextCellIndex;
- }
- }
- else if (bestCellZ is not null)
- {
- // Staying in the same indoor cell.
- targetZ = bestCellZ.Value;
- targetCellId = bestCell!.CellId & 0xFFFFu;
- }
- else
- {
- // No cell floor found and no portal crossed — fall back to outdoor.
- targetZ = terrainZ;
- targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
- }
- }
- else
- {
- // Outdoor player: check for a portal crossing into an indoor cell.
- // Outside-facing portals have TargetCellId == 0xFFFF (they face the
- // outdoor world); crossing one from the outdoor side enters the OwnerCellId.
- PortalPlane? crossedPortal = null;
- foreach (var portal in physics.Portals)
- {
- if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals
- if (portal.IsCrossing(currentPos, candidatePos))
- {
- crossedPortal = portal;
- break;
- }
- }
-
- if (crossedPortal is not null)
- {
- // Outdoor → Indoor: enter the OwnerCellId IF the target cell
- // actually contains the candidate position. Without CellBSP,
- // we verify by checking that SampleFloorZ returns non-null
- // (position is within the cell's floor polygon bounds) AND the
- // floor Z is close to the player's current Z (not a basement
- // 30m below). This prevents the wall-bounce bug where portal
- // planes on upper floors captured outdoor positions.
- uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu;
- CellSurface? enterCell = null;
- foreach (var c in physics.Cells)
- {
- if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; }
- }
- float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
-
- // Validate: floor must exist AND be within step height of current Z.
- // This rejects transitions to basements, upper floors, and cells
- // whose floor polygon doesn't actually cover this position.
- bool validTransition = enterFloorZ is not null
- && MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f;
-
- if (validTransition)
- {
- targetZ = enterFloorZ!.Value;
- targetCellId = enterCellIndex;
- }
- else
- {
- // Portal crossed but target cell doesn't contain us — stay outdoor.
- targetZ = terrainZ;
- targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
- }
- }
- else
- {
- // Stay outdoors on terrain.
- targetZ = terrainZ;
- targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
-
- // #126 (2026-06-11, RETAIL-CORRECTED same day): a zero-delta
- // RESTORE commits the server's position — it does NOT
- // re-derive Z. Retail CPhysicsObj::SetPositionInternal
- // (0x00515bd0, pc:283892-283945) treats the supplied Position
- // as INPUT: AdjustPosition resolves which cell CONTAINS it,
- // CheckPositionInternal/find_valid_position VALIDATES it
- // through the collision transition, and failure goes
- // store_position + GotoLostCell — there is NO terrain/surface
- // re-grounding anywhere in the restore path. Our previous
- // shapes both diverged: grounding to terrainZ warped a
- // roof-deck logout (ACE's authoritative z=127.2 on the AAB3
- // tower) THROUGH the roof into the building volume → the
- // transparent-interior spawn; the cell-walkable scan that
- // replaced it missed shell-geometry decks entirely (no
- // EnvCell owns the surface) and failed silently. Trust the
- // claim's Z; the first physics tick validates/settles against
- // the REAL collision world (BR-7 building channel included).
- // max(terrain, z) stays as the under-terrain sanity bound —
- // our recoverable stand-in for retail's lost-cell machinery
- // (documented divergence, same as the #107 demote).
- if (snapDiag
- && currentPos.Z > terrainZ)
- {
- if (DiagnosticLog is { } outdoorLog)
- {
- outdoorLog(System.FormattableString.Invariant(
- $"[snap] OUTDOOR claim 0x{cellId:X8} z={currentPos.Z:F3} above terrain {terrainZ:F3} — committing the server Z (retail SetPositionInternal shape; physics settles on tick 1)"));
- }
- targetZ = currentPos.Z;
- }
- }
- }
-
- // Step-height enforcement: block upward movement that exceeds the limit.
- float zDelta = targetZ - currentPos.Z;
- if (snapDiag && DiagnosticLog is { } resultLog)
- resultLog(System.FormattableString.Invariant(
- $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cells={physics.Cells.Count} bestCell=0x{(bestCell?.CellId ?? 0u):X8} bestZ={(bestCellZ?.ToString("F3") ?? "none")} terrainZ={terrainZ:F3} indoor={currentlyIndoor} -> targetZ={targetZ:F3} targetCell=0x{(lbPrefix | (targetCellId & 0xFFFFu)):X8} stepReject={zDelta > stepUpHeight}"));
- if (zDelta > stepUpHeight)
- {
- // Too steep to step up — reject horizontal movement.
- return new ResolveResult(currentPos, cellId, IsOnGround: true);
- }
-
- return new ResolveResult(
- new Vector3(candidatePos.X, candidatePos.Y, targetZ),
- lbPrefix | (targetCellId & 0xFFFFu),
- IsOnGround: true);
- }
-
///
/// Resolve movement using the CTransition sphere-sweep system.
/// Subdivides movement into sphere-radius steps, tests terrain collision
- /// at each step, handles step-down for ground contact.
- /// Falls back to the simple if the transition fails.
+ /// at each step, handles step-down for ground contact. C5a (2026-08-05):
+ /// the legacy simple-snap fallback this method used to describe (the
+ /// zero-delta Resolve player-snap path) is gone — deleted with
+ /// zero production callers. This is now the sole movement-resolution
+ /// entry point; a failed transition returns its own
+ /// false rather than falling back to
+ /// anything.
///
///
/// is optional but highly recommended for movement
@@ -2736,64 +2385,4 @@ public sealed class PhysicsEngine
ReturnTransition(transition);
}
}
-
- ///
- /// Runs retail's radius-aware placement-ring search after the host has
- /// validated the server's cell and grounded the initial position. This is
- /// the CTransition::find_placement_pos half of enter-world
- /// SetPosition; unlike an ordinary zero-distance transition it tests
- /// object occupancy and can seat a relogging player beside a creature that
- /// now occupies the saved location.
- ///
- public ResolveResult ResolvePlacement(
- Vector3 position,
- uint cellId,
- float sphereRadius,
- float sphereHeight,
- float stepUpHeight,
- float stepDownHeight,
- ObjectInfoState moverFlags = ObjectInfoState.None,
- uint movingEntityId = 0)
- {
- var transition = RentTransition();
- try
- {
- transition.ObjectInfo.StepUpHeight = stepUpHeight;
- transition.ObjectInfo.StepDownHeight = stepDownHeight;
- transition.ObjectInfo.StepDown = true;
- transition.ObjectInfo.SelfEntityId = movingEntityId;
- transition.ObjectInfo.State = moverFlags;
- transition.SpherePath.InitPath(
- position, position, cellId, sphereRadius, sphereHeight);
- transition.SpherePath.InsertType = InsertType.Placement;
-
- bool ok = transition.FindPlacementPos(this);
- var sp = transition.SpherePath;
- var ci = transition.CollisionInfo;
- bool inContact = ci.ContactPlaneValid;
- bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
- inContact,
- ci.ContactPlane.Normal);
- bool onGround = inContact
- || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
-
- return new ResolveResult(
- sp.CurPos,
- sp.CurCellId != 0 ? sp.CurCellId : cellId,
- onGround,
- ci.CollisionNormalValid,
- ci.CollisionNormal,
- ok,
- Orientation: sp.CurOrientation,
- InContact: inContact,
- OnWalkable: onWalkable,
- ContactPlane: ci.ContactPlane,
- ContactPlaneCellId: ci.ContactPlaneCellId,
- ContactPlaneIsWater: ci.ContactPlaneIsWater);
- }
- finally
- {
- ReturnTransition(transition);
- }
- }
}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index 9ca78cea..28733b8d 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -3,7 +3,7 @@ using System.Numerics;
namespace AcDream.Core.Physics;
///
-/// Result of : the validated
+/// Result of : the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
///
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
index a2bd6c15..f0697b4e 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
@@ -791,9 +791,14 @@ internal sealed class HeadlessSessionWorldProjection
/// C4 route 3 (D-T6): the portal-arrival placement runs through the
/// SAME canonical Runtime portal arm the graphical host drives
/// (),
- /// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority
- /// this method used to own directly (D2;
- /// docs/research/2026-08-04-c4-route-3-contract.md D-T6).
+ /// retiring the duplicate placement authority this method used to own
+ /// directly (D2; docs/research/2026-08-04-c4-route-3-contract.md D-T6).
+ /// C5a (2026-08-05): that duplicate authority forecast here is now
+ /// closed for real — the legacy PhysicsEngine.Resolve/
+ /// ResolvePlacement pair this note used to name is deleted
+ /// outright, zero production callers; the sole canonical placement path
+ /// today is PhysicsEngine.SetPosition via
+ /// RuntimeSetPositionState.
///
///
/// A1/A3 review fix (2026-08-05): the first pass discarded the arm's
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 57a164f5..d8968b89 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -142,8 +142,11 @@ internal enum PlayerMovementControllerPublicationLifecycle
/// velocity clamping — all from the decompiled retail client.
/// - MotionInterpreter owns the motion state machine: walk/run/jump
/// validation, state tracking, speed constants from the retail dat.
-/// - PhysicsEngine.Resolve is still used each frame to snap the player
-/// to terrain/cell floor Z and detect ground contact.
+/// - PhysicsEngine.ResolveWithTransition is still used each frame to snap
+/// the player to terrain/cell floor Z and detect ground contact (C5a,
+/// 2026-08-05: the legacy PhysicsEngine.Resolve this note used to name
+/// is deleted, zero production callers; ResolveWithTransition's
+/// sphere-sweep resolver is, and always was, the real per-frame path).
///
public sealed class PlayerMovementController
{
@@ -1743,21 +1746,19 @@ public sealed class PlayerMovementController
_physics.UpdatePlayerCurrCell(newCellId);
}
- public void SetPosition(Vector3 pos, uint cellId)
- // #145: tests + legacy callers run in the world==block-local frame (no
- // streaming center), so the cell-local seed IS the world position. This
- // makes the carried anchor (body.Position − CellPosition.Origin) == (0,0,0),
- // identical to the legacy Zero terrain-origin fallback → behaviour unchanged.
- => SetPosition(pos, cellId, pos);
-
///
- /// Server-snap / teleport placement. is the
- /// LANDBLOCK-relative position (the wire's local, or world − landblock origin)
- /// which seeds the body's cell-relative CellPosition WITHOUT any streaming
- /// center (#145). A teleport is a large jump, so this snaps the cell frame
- /// directly via SnapToCell rather than delta-syncing through the setter.
+ /// C5a (2026-08-05): this seed exists ONLY to place a controller directly
+ /// in test fixtures. Production placement never calls it — the retail
+ /// server-snap / teleport / enter-world path commits through
+ /// followed by
+ /// (or, for the
+ /// already-live case, canonical SetPositionCore callers inside
+ /// this class). is the LANDBLOCK-relative
+ /// position (the wire's local, or world − landblock origin) which seeds
+ /// the body's cell-relative CellPosition WITHOUT any streaming
+ /// center (#145).
///
- public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
+ internal void SeedPlacementForTest(Vector3 pos, uint cellId, Vector3 cellLocal)
{
EnsurePublishedForRuntimeOperation();
SetPositionCore(
@@ -1786,22 +1787,14 @@ public sealed class PlayerMovementController
publishSharedState: false);
}
- internal void CommitPreparedPosition()
- {
- EnsurePublishedForRuntimeOperation();
- _physics.UpdatePlayerCurrCell(CellId);
- PositionManager?.UnStick();
- // #167 (Campaign P P5): mirrors the SetPositionCore teleport_hook
- // teardown+rearm below — see that comment for the retail citation.
- RearmConstraintLeashAtCurrentPosition();
- }
-
///
/// C3c-R1: arms the login-entry constraint leash from the Runtime
/// publication chain. The flip deleted the only login-path caller of
/// (the App-side
- /// call in the old
- /// player-mode-entry commit); the dormant activation's final commit
+ /// CommitPreparedPosition call in the old
+ /// player-mode-entry commit, removed C5a — production placement now
+ /// arms exclusively here and at 's
+ /// teleport_hook teardown+rearm); the dormant activation's final commit
/// (RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit)
/// is the accepted-position event that replaces it — retail arms at
/// every accepted-position event (SmartBox::HandleReceivedPosition
@@ -1824,9 +1817,11 @@ public sealed class PlayerMovementController
/// immediately after TeleportPlayer's teardown, anchored to the
/// RECEIVED position (here, the body's just-snapped current position).
/// Shared by the teleport path (after UnConstrain), the deferred
- /// player-mode-entry commit path (),
- /// which never ran UnConstrain because nothing could have armed the
- /// leash before the controller had a ,
+ /// player-mode-entry commit path (formerly the App-side
+ /// CommitPreparedPosition caller, removed C5a; now
+ /// 's Runtime-owned
+ /// caller), which never ran UnConstrain because nothing could have armed
+ /// the leash before the controller had a ,
/// and the C3c first-entry placement commit
/// ().
/// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2.
diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
index ee5eaa9b..9e0d9fb0 100644
--- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
@@ -1318,6 +1318,16 @@ internal sealed class RuntimeSetPositionState : IDisposable
allowDirectUnsealed: true);
}
+ ///
+ /// C5a (2026-08-05): test seam. A pure pass-through to
+ /// BeginAcceptedPlacementCore (captureMoverPreparationAuthority:
+ /// false) kept ONLY so Runtime test fixtures can open a placement
+ /// operation directly; production reaches the core exclusively through
+ /// and the authored sequence. The core it calls IS
+ /// the canonical path, so this wrapper is not a legacy path to delete —
+ /// deleting it would only buy broad mechanical test churn for zero
+ /// behaviour change (C5a scoping, disposition 3.7).
+ ///
internal RuntimeEntityPlacementToken BeginAcceptedPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
@@ -1330,6 +1340,15 @@ internal sealed class RuntimeSetPositionState : IDisposable
portal,
captureMoverPreparationAuthority: false);
+ ///
+ /// C5a (2026-08-05): test seam. A pure pass-through to
+ /// BeginAcceptedPlacementCore (captureMoverPreparationAuthority:
+ /// true) kept ONLY so Runtime test fixtures can open an authored
+ /// placement operation directly; production reaches the core through the
+ /// authored sequence itself. See for
+ /// why this wrapper is a recorded deliberate exception, not a deletion
+ /// target.
+ ///
internal RuntimeEntityPlacementToken BeginAuthoredPlacement(
RuntimeEntityRecord record,
ulong expectedPositionAuthorityVersion,
diff --git a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs
index d5454be3..9f28d301 100644
--- a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs
+++ b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs
@@ -216,11 +216,11 @@ public sealed class RuntimeAcceptedPositionDriveController
/// not re-issue it. The converse does NOT hold: equality does not prove
/// the newest accepted event was this ForcePosition, because
/// RuntimeEntityRecordTable.AdvancePositionAuthority has four call
- /// sites, not one — the ordinary accepted-Position merge
- /// (RuntimeEntityObjectLifetime.cs:1647) plus
- /// TryApplyPickup (:1116),
- /// CommitPositionChannelUpdate (:2041) and
- /// AdvanceCreateAuthority (:2466). The latter three are
+ /// sites, not one, all in RuntimeEntityObjectLifetime (C5a,
+ /// 2026-08-05 — cite by symbol, not line: these move) — the ordinary
+ /// accepted-Position merge plus TryApplyPickup,
+ /// CommitPositionChannelUpdate, and
+ /// AdvanceCreateAuthority. The latter three are
/// effectively unreachable for a live local player, but the funnel's
/// safety does not depend on that: an unnoticed advance can only make the
/// funnel decline a re-issue it might have made (register row
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
index b712d4ec..ce129f6f 100644
--- a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
@@ -15,7 +15,7 @@ public sealed class LocalPlayerProjectionControllerTests
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
var movement = new PlayerMovementController(new PhysicsEngine());
- movement.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
+ movement.SeedPlacementForTest(Vector3.Zero, cellId, Vector3.Zero);
movement.SetBodyOrientation(complete);
var entity = new WorldEntity
{
@@ -53,7 +53,7 @@ public sealed class LocalPlayerProjectionControllerTests
{
const uint cellId = 0x02020001u;
var movement = new PlayerMovementController(new PhysicsEngine());
- movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
+ movement.SeedPlacementForTest(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
var entity = new WorldEntity
{
Id = 8u,
@@ -88,7 +88,7 @@ public sealed class LocalPlayerProjectionControllerTests
{
const uint cellId = 0x02020001u;
var movement = new PlayerMovementController(new PhysicsEngine());
- movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
+ movement.SeedPlacementForTest(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero);
var entity = new WorldEntity
{
Id = 9u,
diff --git a/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs
index 6a8ad131..f05192fe 100644
--- a/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs
@@ -244,7 +244,7 @@ public sealed class MouseLookControllerTests
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}
diff --git a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
index 5183e01b..14a9edaf 100644
--- a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
+++ b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
@@ -15,8 +15,47 @@ public sealed class PlayerMovementPlacementTransactionTests
private const uint PriorCell = 0x0101_0101u;
private const uint DestinationCell = 0x0101_0102u;
+ ///
+ /// C5a (2026-08-05) re-point: PlayerMovementController.CommitPreparedPosition
+ /// is deleted (zero production callers — production placement now arms
+ /// the login-entry leash via ArmConstraintLeashAtCommittedPlacement,
+ /// called from RuntimeLocalPlayerPhysicsPublicationState.ArmFirstEntryConstraintLeash
+ /// AFTER RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit
+ /// has already published the shared current cell — see that method's
+ /// doc comment). Architecture-review correction (A5, 2026-08-05): the two
+ /// deleted invariants did NOT relocate symmetrically.
+ /// Render-root publish DID move — it happens inside
+ /// RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit
+ /// itself, via _physics.Engine.UpdatePlayerCurrCell(result.CellId)
+ /// (RuntimeSetPositionState.cs:2774).
+ /// Sticky-target release did NOT move anywhere — grep -rn
+ /// "UnStick" src/AcDream.Runtime/ returns zero call sites on this
+ /// path; the only local-player UnStick left is
+ /// PlayerMovementController.SetPositionCore's
+ /// if (publishSharedState) PositionManager?.UnStick();, and
+ /// PreparePositionForCommit passes publishSharedState: false.
+ /// The unstick-at-first-entry-commit behaviour CommitPreparedPosition
+ /// used to perform is simply gone — it was dead code (the method had zero
+ /// production callers before this slice deleted it), so nothing regresses
+ /// today, but no layer pins the invariant "first-entry commit releases any
+ /// sticky target" any more. That is disposition 3.6's one real coverage
+ /// loss (see the C5a commit message).
+ /// This App-layer fixture (a bare +
+ /// pair, no Runtime activation pipeline)
+ /// cannot drive the Runtime final-commit transaction without
+ /// disproportionate fixture growth — recorded here as the explicit C5a
+ /// scope decision rather than silently dropped.
+ /// What THIS test still proves, unchanged:
+ /// defers the render-root publish exactly as before (controller.CellId
+ /// updates immediately; physics.DataCache.CellGraph.CurrCell and
+ /// the sticky target stay untouched), and the surviving production
+ /// commit-arm, ,
+ /// arms the constraint leash without independently touching either —
+ /// neither the render-root publish (a DIFFERENT layer's job now) nor the
+ /// sticky release (nobody's job any more).
+ ///
[Fact]
- public void PreparedPosition_DoesNotPublishRenderRootOrUnstickUntilCommit()
+ public void PreparedPosition_DefersRenderRootPublish_CommitArmsLeashOnly()
{
PhysicsEngine physics = PhysicsWithCells(PriorCell, DestinationCell);
physics.UpdatePlayerCurrCell(PriorCell);
@@ -38,11 +77,19 @@ public sealed class PlayerMovementPlacementTransactionTests
Assert.Equal(DestinationCell, controller.CellId);
Assert.Equal(PriorCell, physics.DataCache!.CellGraph.CurrCell!.Id);
Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
+ Assert.False(player.PositionManager.IsFullyConstrained());
+ Assert.Null(player.PositionManager.Constraint);
- controller.CommitPreparedPosition();
+ controller.ArmConstraintLeashAtCommittedPlacement();
- Assert.Equal(DestinationCell, physics.DataCache.CellGraph.CurrCell!.Id);
- Assert.Equal(0u, player.PositionManager.GetStickyObjectId());
+ // The leash is armed...
+ Assert.NotNull(player.PositionManager.Constraint);
+ Assert.True(player.PositionManager.Constraint!.IsConstrained);
+ // ...but the surviving App-layer commit does NOT publish the render
+ // root or release the sticky target — those now happen inside the
+ // Runtime dormant-activation final commit, before this method runs.
+ Assert.Equal(PriorCell, physics.DataCache.CellGraph.CurrCell!.Id);
+ Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
}
private static PhysicsEngine PhysicsWithCells(params uint[] cellIds)
diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
index 67647629..0e5cb823 100644
--- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
@@ -343,7 +343,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
var clock = new RetailObjectQuantumClock();
var controller = new PlayerMovementController(engine, clock);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
Assert.True(clock.IsActive);
return controller;
}
diff --git a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
index 8914ed91..d30d768e 100644
--- a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
+++ b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs
@@ -32,7 +32,7 @@ public sealed class PlayerInteractionMovementSinkTests
MovementType expectedType)
{
var controller = new PlayerMovementController(new PhysicsEngine());
- controller.SetPosition(Vector3.Zero, Cell);
+ controller.SeedPlacementForTest(Vector3.Zero, Cell, Vector3.Zero);
bool nonAutonomousAtTargetInstall = false;
int cancellations = 0;
double targetQuantum = 0d;
diff --git a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
index 4db98835..a9ae439f 100644
--- a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
+++ b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
@@ -19,7 +19,7 @@ public sealed class PlayerMovementHiddenTests
const uint cellId = 0x01010001u;
var controller = new PlayerMovementController(new PhysicsEngine());
- controller.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
+ controller.SeedPlacementForTest(Vector3.Zero, cellId, Vector3.Zero);
var hosts = new Dictionary();
EntityPhysicsHost? playerHost = null;
diff --git a/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs
index 3cc6580b..8e75efe9 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs
@@ -158,7 +158,7 @@ public sealed class CameraFrameControllerTests
0f,
0f);
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
index 9ed09749..f08a6a8b 100644
--- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -89,7 +89,7 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Null(harness.Presentation.BeginProjection);
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
- harness.Mode.Controller.SetPosition(
+ harness.Mode.Controller.SeedPlacementForTest(
Vector3.Zero,
0x20210001u,
Vector3.Zero);
@@ -117,7 +117,7 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Empty(harness.Streaming.Reservations);
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
- harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ harness.Mode.Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
harness.Controller.Tick(0.016f);
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
@@ -156,7 +156,7 @@ public sealed class LocalPlayerTeleportControllerTests
harness.Mode.RebuildOnEnter = () =>
{
var controller = new PlayerMovementController(new PhysicsEngine());
- controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
return controller;
};
harness.Controller.Tick(0.016f);
@@ -353,7 +353,7 @@ public sealed class LocalPlayerTeleportControllerTests
// AddSyntheticIndoorCell doc comment for why CommitLandblockCollision
// alone (terrain-only) cannot make it spawn-ready.
harness.AddSyntheticIndoorCell(0x30310100u);
- harness.Mode.Controller!.SetPosition(
+ harness.Mode.Controller!.SeedPlacementForTest(
Vector3.Zero,
0x20210001u,
Vector3.Zero);
@@ -649,7 +649,7 @@ public sealed class LocalPlayerTeleportControllerTests
// body/cell/orientation BEFORE this suffix runs. Reproduce that
// pre-state directly rather than re-resolving it here - Place() no
// longer resolves anything (D-T4).
- controllerSlot.Controller.SetPosition(position, cell, position);
+ controllerSlot.Controller.SeedPlacementForTest(position, cell, position);
controllerSlot.Controller.SetBodyOrientation(rotation);
var cameras = new ChaseCameraInputState
{
@@ -727,14 +727,14 @@ public sealed class LocalPlayerTeleportControllerTests
{
Controller = new PlayerMovementController(new PhysicsEngine()),
};
- controllerSlot.Controller.SetPosition(
+ controllerSlot.Controller.SeedPlacementForTest(
Vector3.Zero,
sourceCell,
Vector3.Zero);
// C4 route 3: reproduce the canonical Runtime commit's pre-state
// (body/cell already resolved) - Place() no longer resolves it.
var destinationPosition = new Vector3(12f, 24f, 6f);
- controllerSlot.Controller.SetPosition(
+ controllerSlot.Controller.SeedPlacementForTest(
destinationPosition,
destinationCell,
destinationPosition);
@@ -1303,7 +1303,7 @@ public sealed class LocalPlayerTeleportControllerTests
{
_order = order;
Controller = new PlayerMovementController(new PhysicsEngine());
- Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
+ Controller.SeedPlacementForTest(Vector3.Zero, 0x20210001u, Vector3.Zero);
}
public PlayerMovementController? Controller { get; set; }
diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs
index ae029adb..4f309d91 100644
--- a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs
@@ -139,7 +139,7 @@ public sealed class StreamingFrameControllerTests
var fixture = new Fixture(initializeOrigin: true);
fixture.Mode.IsPlayerMode = true;
var player = new PlayerMovementController(new PhysicsEngine());
- player.SetPosition(
+ player.SeedPlacementForTest(
new Vector3(383f, -0.01f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));
@@ -159,7 +159,7 @@ public sealed class StreamingFrameControllerTests
{
State = PlayerState.PortalSpace,
};
- player.SetPosition(
+ player.SeedPlacementForTest(
new Vector3(1000f, -1000f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));
@@ -179,7 +179,7 @@ public sealed class StreamingFrameControllerTests
var fixture = new Fixture(initializeOrigin: true);
fixture.Mode.IsPlayerMode = true;
var player = new PlayerMovementController(new PhysicsEngine());
- player.SetPosition(
+ player.SeedPlacementForTest(
new Vector3(1000f, -1000f, 0f),
0x0A140001u,
new Vector3(1f, 1f, 0f));
diff --git a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs
index 2769e3ed..7a272c2e 100644
--- a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs
+++ b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs
@@ -86,7 +86,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // facing +X
var kb = new FakeKb();
@@ -117,7 +117,7 @@ public class DispatcherToMovementIntegrationTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var kb = new FakeKb();
@@ -165,12 +165,12 @@ public class DispatcherToMovementIntegrationTests
// frame's MouseDeltaX doesn't leak into the second run via Yaw.
var engineA = MakeFlatEngine();
var ctrlA = new PlayerMovementController(engineA);
- ctrlA.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ ctrlA.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlA.Yaw = 0f;
var engineB = MakeFlatEngine();
var ctrlB = new PlayerMovementController(engineB);
- ctrlB.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ ctrlB.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ctrlB.Yaw = 0f;
var inputZero = new MovementInput(Forward: true, MouseDeltaX: 0f);
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
index f9b47ebc..5914bcf9 100644
--- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
+++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
@@ -161,7 +161,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // heading 90 = facing +X
const uint selfGuid = 0x5000000Au;
diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
index aa80104e..e1e9abc9 100644
--- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
+++ b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs
@@ -108,7 +108,7 @@ public class W6EdgeDrivenMovementTests
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
return controller;
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
index 65ebf807..57852bd7 100644
--- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
@@ -236,7 +236,7 @@ public class CellarUpTrajectoryReplayTests : IDisposable
{
var (engine, _) = BuildEngineWithCellarFixtures();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(InitialSphereWorld, CellarId);
+ controller.SeedPlacementForTest(InitialSphereWorld, CellarId, InitialSphereWorld);
var settled = controller.Update(1f / 60f, new MovementInput());
var basePos = settled.Position;
diff --git a/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs
index fd3b6e9c..ec67b1a0 100644
--- a/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
@@ -6,6 +7,23 @@ namespace AcDream.Core.Tests.Physics;
///
/// Enter-world overlap conformance for retail
/// CTransition::find_placement_pos (0x0050BA50).
+///
+///
+/// C5a (2026-08-05) re-point: the legacy PhysicsEngine.ResolvePlacement
+/// this test originally drove is deleted (zero production callers). Canonical
+/// PhysicsEngine.SetPosition reaches the SAME ring-search machinery —
+/// SetPositionInternal sets InsertType.Placement before calling
+/// Transition.FindValidPosition, which (for any non-Transition
+/// insert type) dispatches to FindPlacementPosition
+/// (TransitionTypes.cs), and THAT method calls this exact
+/// FindPlacementPos ring search after its initial-placement insert —
+/// so this is not a new code path, only a new entry point onto the one the
+/// legacy method used directly. Sphere shape is reconstructed as the
+/// two-sphere capsule the legacy scalar (radius, height) overload
+/// built internally (origin (0,0,radius) + (0,0,height-radius), both
+/// radius-sized), since canonical SetPosition takes a pre-built
+/// sphere list rather than a radius/height pair.
+///
///
public sealed class InitialPlacementOverlapTests
{
@@ -14,6 +32,7 @@ public sealed class InitialPlacementOverlapTests
private const uint PlayerId = 0x50000001u;
private const uint MonsterId = 0x50000002u;
private const float Radius = 0.48f;
+ private const float SphereHeight = 1.835f;
[Fact]
public void PlayerReloggingInsideMonster_SearchesOutToNearestClearRing()
@@ -39,17 +58,30 @@ public sealed class InitialPlacementOverlapTests
RegisterSphere(engine, MonsterId, monsterCenter,
EntityCollisionFlags.IsCreature);
- ResolveResult result = engine.ResolvePlacement(
- savedFeet,
- Cell,
- sphereRadius: Radius,
- sphereHeight: 1.835f,
- stepUpHeight: 0.4f,
- stepDownHeight: 0.4f,
- moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
- movingEntityId: PlayerId);
+ // The mover's own two-sphere capsule, matching the legacy scalar
+ // InitPath(sphereRadius: 0.48, sphereHeight: 1.835) reconstruction:
+ // a foot sphere at (0,0,radius) and a head sphere at
+ // (0,0,height-radius), both radius-sized.
+ ImmutableArray spheres = ImmutableArray.Create(
+ new FlatCollisionSphere(new Vector3(0f, 0f, Radius), Radius),
+ new FlatCollisionSphere(new Vector3(0f, 0f, SphereHeight - Radius), Radius));
- Assert.True(result.Ok);
+ PhysicsSetPositionResult result = engine.SetPosition(
+ new PhysicsSetPositionRequest(
+ Position: savedFeet,
+ Orientation: Quaternion.Identity,
+ CellId: Cell,
+ CellLocalPosition: savedFeet,
+ Spheres: spheres,
+ Scale: 1f,
+ StepUpHeight: 0.4f,
+ StepDownHeight: 0.4f,
+ MoverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ MovingEntityId: PlayerId,
+ Flags: PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Slide));
+
+ Assert.True(result.IsCommitted);
Assert.True(result.InContact);
Assert.True(result.OnWalkable);
Assert.Equal(Cell, result.ContactPlaneCellId);
diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
index 8cb3606a..e8a7e58b 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
@@ -9,32 +9,49 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
///
-/// #133 (Bug A) — the validated-claim placement branch of
-/// must return the VALIDATED claim's own
-/// full cell id, NOT lbPrefix | (cellId & 0xFFFF).
+/// #133 (Bug A) — a validated dungeon claim's landblock prefix is
+/// AUTHORITATIVE; a candidate world position that also falls inside a
+/// neighbouring RESIDENT landblock's [0,192) local bounds must NOT
+/// re-stamp the claim with that neighbour's prefix.
+///
+///
+/// C5a (2026-08-05) re-point: the legacy PhysicsEngine.Resolve this
+/// test originally drove is deleted (zero production callers — every
+/// production placement writer reaches canonical
+/// PhysicsEngine.SetPosition through RuntimeSetPositionState).
+/// The defect this test pins lived entirely inside Resolve's own
+/// lbPrefix resident-landblock scan (find a resident block whose
+/// [0,192) bounds contain the candidate XY, then stamp
+/// lbPrefix | (cellId & 0xFFFF) — the scan has no notion that
+/// the claim's OWN landblock can legitimately fail its own bounds test,
+/// which is exactly what a dungeon EnvCell's negative local Y does). The
+/// canonical SetPosition/SetPositionInternal pipeline has no
+/// equivalent lbPrefix scan: a validated claim (AdjustSeedCell
+/// confirms the seed cell contains the point) is committed with ITS OWN
+/// cell id, full stop — the defect class cannot recur there by
+/// construction. This test now proves that positively: drive the SAME
+/// geometry as the original #133 capture (dungeon claim
+/// 0x00070143 at dungeon-local (70,-60,0.01), with a
+/// still-resident Holtburg neighbour block whose [0,192) bounds
+/// contain the same world XY) through canonical
+/// PhysicsEngine.SetPosition and assert the committed cell keeps
+/// the 0x0007 prefix.
+///
///
///
/// lbPrefix is found by scanning resident landblocks for one whose
/// [0,192) local bounds contain the candidate XY. A dungeon EnvCell's
/// local Y can be NEGATIVE relative to its own landblock (the live capture:
/// server teleport to dungeon cell 0x00070143 at local (70,-60,0.01)).
-/// The dungeon landblock fails the localY >= 0 bounds test, so the loop
-/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock
-/// whose world bounds happen to contain the same XY) and sets
-/// lbPrefix = 0xA9B30000. The old code then returned
+/// The dungeon landblock fails the localY >= 0 bounds test, so the
+/// legacy scan instead matched a still-resident NEIGHBOURING block (a
+/// Holtburg landblock whose world bounds happen to contain the same XY) and
+/// set lbPrefix = 0xA9B30000. The old code then returned
/// 0xA9B30000 | 0x0143 = 0xA9B30143, re-stamping the validated dungeon
/// claim with the wrong landblock — the client mis-resolved the player into
/// Holtburg and spammed ACE with rejected moves
/// (movement pre-validation failed from 00070143 to A9B30143).
///
-///
-///
-/// The validated claim's prefix is authoritative; a position falling in a
-/// neighbouring resident landblock must not re-stamp it. This test reproduces
-/// the exact geometry of the capture (dungeon claim in landblock 0x0007,
-/// candidate XY also inside resident Holtburg 0xA9B3) and asserts the
-/// returned cell keeps its 0x0007 prefix.
-///
///
public class Issue133DungeonTeleportPrefixTests
{
@@ -53,13 +70,29 @@ public class Issue133DungeonTeleportPrefixTests
{
var engine = BuildEngine();
- // Zero delta = the snap shape (teleport arrival). cellId is the dungeon
- // claim; the candidate XY also falls inside the resident Holtburg block.
- var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f);
+ // Zero-delta teleport arrival — canonical SetPosition's placement
+ // shape for a server-restored (cell, position) pair. The dungeon
+ // claim is the seed cell; the candidate XY also falls inside the
+ // resident Holtburg block's [0,192) bounds.
+ PhysicsSetPositionResult result = engine.SetPosition(
+ new PhysicsSetPositionRequest(
+ Position: SpawnPos,
+ Orientation: Quaternion.Identity,
+ CellId: DungeonCellId,
+ CellLocalPosition: SpawnPos,
+ Spheres: default,
+ Scale: 1f,
+ StepUpHeight: 0.4f,
+ StepDownHeight: 0.4f,
+ Flags: PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Teleport
+ | PhysicsSetPositionFlags.Slide));
- Assert.True(result.IsOnGround);
+ Assert.True(result.IsCommitted);
// The validated claim's prefix is authoritative — high word stays 0x0007,
- // NOT re-stamped to the neighbouring Holtburg 0xA9B3.
+ // NOT re-stamped to the neighbouring Holtburg 0xA9B3. Canonical
+ // SetPosition has no lbPrefix scan to get this wrong in the first
+ // place; this assertion is the positive proof of that.
Assert.Equal(DungeonCellId, result.CellId);
Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u);
}
@@ -73,12 +106,13 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition
// validates the claim (returns it with found=true). Its Resolved set has
- // one walkable floor polygon at z=0 under the spawn XY so the #111
- // validated-claim branch grounds onto it.
+ // one walkable floor polygon at z=0 under the spawn XY so placement
+ // grounds onto it.
cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell());
// Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the
- // candidate XY (70,70). This is the block the lbPrefix loop wrongly matched.
+ // candidate XY (70,70). This is the block the legacy lbPrefix loop wrongly
+ // matched.
engine.AddLandblock(
landblockId: HoltburgLandblock,
terrain: FlatTerrain(),
@@ -89,7 +123,7 @@ public class Issue133DungeonTeleportPrefixTests
// The dungeon's own landblock, offset so the candidate XY produces a
// NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds
- // test, which is exactly why the old code fell through to the Holtburg
+ // test, which is exactly why the legacy code fell through to the Holtburg
// prefix. Registered so the scenario is faithful (a resident dungeon block
// whose local bounds don't cover the EnvCell's negative-Y position).
engine.AddLandblock(
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index aea39519..3d975ca4 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -31,192 +31,6 @@ public class PhysicsEngineTests
return engine;
}
- [Fact]
- public void Resolve_ZeroDeltaSnapTrace_IsExplicitlyOptIn()
- {
- var engine = new PhysicsEngine();
- var diagnostics = new List();
-
- Assert.Null(engine.DiagnosticLog);
- _ = engine.Resolve(
- Vector3.Zero,
- cellId: 0xA9B40001u,
- delta: Vector3.Zero,
- stepUpHeight: 0.5f);
-
- engine.DiagnosticLog = diagnostics.Add;
- _ = engine.Resolve(
- Vector3.Zero,
- cellId: 0xA9B40001u,
- delta: Vector3.Zero,
- stepUpHeight: 0.5f);
-
- Assert.Single(diagnostics);
- Assert.StartsWith(
- "[snap] claim=0xA9B40001",
- diagnostics[0],
- StringComparison.Ordinal);
- }
-
- [Fact]
- public void Resolve_FlatTerrain_ZMatchesTerrain()
- {
- var engine = MakeFlatEngine(terrainZ: 50f);
-
- var result = engine.Resolve(
- new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
- stepUpHeight: 2f);
-
- Assert.Equal(50f, result.Position.Z, precision: 1);
- Assert.True(result.IsOnGround);
- }
-
- [Fact]
- public void Resolve_WalkUpSmallSlope_Accepted()
- {
- // Heights slope from 50 to 52 across X — small enough for step height.
- var heights = new byte[81];
- for (int x = 0; x < 9; x++)
- for (int y = 0; y < 9; y++)
- heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
-
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(heights, LinearHeightTable());
- engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- var result = engine.Resolve(
- new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
- stepUpHeight: 5f);
-
- Assert.True(result.IsOnGround);
- Assert.True(result.Position.Z >= 50f); // moved uphill
- }
-
- [Fact]
- public void Resolve_StepUpExceedsHeight_MovementBlocked()
- {
- // Heights jump sharply: left half = 50, right half = 100.
- var heights = new byte[81];
- for (int x = 0; x < 9; x++)
- for (int y = 0; y < 9; y++)
- heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
-
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(heights, LinearHeightTable());
- engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // Try to walk from the low side to the high side.
- var result = engine.Resolve(
- new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
- stepUpHeight: 2f);
-
- // Movement should be blocked — Z delta (50→100) exceeds step height (2).
- Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
- Assert.True(result.IsOnGround);
- }
-
- [Fact]
- public void Resolve_OutdoorThroughPortal_TransitionsToIndoor()
- {
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
-
- // A CellSurface for the indoor cell with floor at Z=50.
- var cellVerts = new Dictionary
- {
- [0] = new(40f, 40f, 50f),
- [1] = new(60f, 40f, 50f),
- [2] = new(60f, 60f, 50f),
- [3] = new(40f, 60f, 50f),
- };
- var cellPolys = new List> { new() { 0, 1, 2, 3 } };
- var cell = new CellSurface(0x0100, cellVerts, cellPolys);
-
- // A portal plane at X=45 (vertical plane facing +X).
- // OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor).
- // From outside, walking through this portal enters OwnerCellId.
- var portal = PortalPlane.FromVertices(
- new Vector3(45f, 40f, 45f),
- new Vector3(45f, 60f, 45f),
- new Vector3(45f, 60f, 55f),
- targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
-
- engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor).
- var result = engine.Resolve(
- new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f),
- stepUpHeight: 5f);
-
- // Should have transitioned to indoor cell 0x0100.
- Assert.Equal(0x0100u, result.CellId & 0xFFFFu);
- Assert.True(result.IsOnGround);
- }
-
- [Fact]
- public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor()
- {
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
-
- var cellVerts = new Dictionary
- {
- [0] = new(40f, 40f, 50f),
- [1] = new(60f, 40f, 50f),
- [2] = new(60f, 60f, 50f),
- [3] = new(40f, 60f, 50f),
- };
- var cellPolys = new List> { new() { 0, 1, 2, 3 } };
- var cell = new CellSurface(0x0100, cellVerts, cellPolys);
-
- // Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit).
- var portal = PortalPlane.FromVertices(
- new Vector3(45f, 40f, 45f),
- new Vector3(45f, 60f, 45f),
- new Vector3(45f, 60f, 55f),
- targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0);
-
- engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal },
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor).
- var result = engine.Resolve(
- new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f),
- stepUpHeight: 5f);
-
- // Should have transitioned to outdoor.
- Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
- Assert.True(result.IsOnGround);
- }
-
- [Fact]
- public void Resolve_LandblockBoundary_PicksAdjacentTerrain()
- {
- var engine = new PhysicsEngine();
-
- // Landblock A: flat at Z=50, offset at X=0.
- var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
- engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(),
- Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f);
-
- // Landblock B: flat at Z=60, offset at X=192 (adjacent east).
- var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable());
- engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(),
- Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f);
-
- // Walk from X=190 (landblock A) across to X=194 (landblock B).
- var result = engine.Resolve(
- new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f),
- stepUpHeight: 15f);
-
- // Should be at Z=60 (landblock B's terrain) and position X≈194.
- Assert.Equal(60f, result.Position.Z, precision: 1);
- Assert.True(result.Position.X > 192f);
- }
-
[Fact]
public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
{
@@ -368,102 +182,6 @@ public class PhysicsEngineTests
Assert.Equal(0xAAB40001u, result.CellId);
}
- [Fact]
- public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
- {
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
-
- var cellVerts = new Dictionary
- {
- [0] = new(40f, 40f, 55f),
- [1] = new(60f, 40f, 55f),
- [2] = new(60f, 60f, 55f),
- [3] = new(40f, 60f, 55f),
- };
- var cellPolys = new List> { new() { 0, 1, 2, 3 } };
- var cell = new CellSurface(0x0100, cellVerts, cellPolys);
-
- engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // Start inside the cell, walk out.
- var result = engine.Resolve(
- new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
- stepUpHeight: 10f);
-
- // Should transition back to outdoor. (#106 follow-up: masked compare —
- // Resolve now returns FULL prefixed cell ids; the old unmasked
- // `CellId < 0x0100` assertion codified the bare-low-word bug.)
- Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
- Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u);
- Assert.Equal(50f, result.Position.Z, precision: 1);
- Assert.True(result.IsOnGround);
- }
-
- ///
- /// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by
- /// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor
- /// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole
- /// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads
- /// "indoor primary" and skips the outdoor object sweep (no collision with
- /// anything), and FindCellSet can never re-resolve a malformed id. Resolve
- /// MUST return the matched landblock's full 32-bit cell id on every
- /// computed exit — the same convention its own inputs use.
- ///
- [Fact]
- public void Resolve_IndoorStay_ReturnsFullPrefixedCellId()
- {
- var engine = new PhysicsEngine();
- var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
-
- var cellVerts = new Dictionary
- {
- [0] = new(40f, 40f, 55f),
- [1] = new(60f, 40f, 55f),
- [2] = new(60f, 60f, 55f),
- [3] = new(40f, 60f, 55f),
- };
- var cellPolys = new List> { new() { 0, 1, 2, 3 } };
- var cell = new CellSurface(0x0100, cellVerts, cellPolys);
-
- engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // The teleport shape: full indoor cell id in, zero delta (pure snap).
- var result = engine.Resolve(
- new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero,
- stepUpHeight: 5f);
-
- Assert.Equal(0xA9B40100u, result.CellId);
- }
-
- [Fact]
- public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId()
- {
- var engine = MakeFlatEngine(terrainZ: 50f);
-
- var result = engine.Resolve(
- new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f),
- stepUpHeight: 2f);
-
- // (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed.
- Assert.Equal(0xA9B40025u, result.CellId);
- }
-
- [Fact]
- public void Resolve_NoSurfaceUnderEntity_NotOnGround()
- {
- var engine = new PhysicsEngine();
- // No landblocks loaded — entity is floating in void.
-
- var result = engine.Resolve(
- new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
- stepUpHeight: 2f);
-
- Assert.False(result.IsOnGround);
- }
-
///
/// #42 lock — when the moving entity's own ShadowEntry is registered
/// in at the body's exact position
diff --git a/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs
index 5c099e8c..3ff987da 100644
--- a/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using AcDream.Core.Physics;
@@ -176,57 +177,95 @@ public sealed class TransitionScratchDifferentialTests
Coverage: CoverageKind.Failure));
}
+ ///
+ /// C5a (2026-08-05) re-point: the legacy PhysicsEngine.ResolvePlacement
+ /// this arm originally drove is deleted (zero production callers).
+ /// Canonical PhysicsEngine.SetPosition reaches the exact same
+ /// FindPlacementPos ring search — SetPositionInternal sets
+ /// InsertType.Placement before Transition.FindValidPosition,
+ /// which dispatches to FindPlacementPosition, which calls
+ /// FindPlacementPos — so this is the same scratch-reuse surface
+ /// under a new entry point, not a new code path. The bitwise
+ /// fresh-vs-reused comparison and the second-identity leak check are both
+ /// preserved verbatim, now via
+ /// over .
+ ///
[Fact]
public void ReusedScratch_MatchesFreshPlacementSearch()
{
PhysicsEngine fresh = BuildPlacementEngine(reuse: false);
PhysicsEngine reused = BuildPlacementEngine(reuse: true);
- ResolveResult expected = fresh.ResolvePlacement(
- new Vector3(10f, 10f, 0f),
- Cell,
- 0.48f,
- 1.835f,
- 0.40f,
- 0.40f,
- ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
- 0x50000101u);
- ResolveResult actual = reused.ResolvePlacement(
- new Vector3(10f, 10f, 0f),
- Cell,
- 0.48f,
- 1.835f,
- 0.40f,
- 0.40f,
- ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
- 0x50000101u);
+ PhysicsSetPositionResult expected = fresh.SetPosition(
+ PlacementRequest(
+ new Vector3(10f, 10f, 0f),
+ ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ 0x50000101u));
+ PhysicsSetPositionResult actual = reused.SetPosition(
+ PlacementRequest(
+ new Vector3(10f, 10f, 0f),
+ ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ 0x50000101u));
- AssertResolveBitwise(expected, actual, "placement");
+ AssertSetPositionBitwise(expected, actual, "placement");
+ // A6 (architecture review, 2026-08-05): the bitwise comparison alone
+ // proves fresh and reused AGREE, not that either actually placed
+ // anything — a future regression that makes SetPosition fail
+ // IDENTICALLY on both engines would leave this differential green
+ // while the scratch-reuse surface it exists to guard goes
+ // unexercised. Assert the positive fact the comparison itself can't:
+ // this placement committed.
+ Assert.True(expected.IsCommitted, "fresh engine did not commit the placement");
+ Assert.True(actual.IsCommitted, "reused engine did not commit the placement");
// A second placement with a different self identity proves that the
// previous mover and collision-GUID list cannot leak through the lease.
- expected = fresh.ResolvePlacement(
- new Vector3(11f, 10f, 0f),
- Cell,
- 0.48f,
- 1.835f,
- 0.40f,
- 0.40f,
- ObjectInfoState.EdgeSlide,
- 0x80000102u);
- actual = reused.ResolvePlacement(
- new Vector3(11f, 10f, 0f),
- Cell,
- 0.48f,
- 1.835f,
- 0.40f,
- 0.40f,
- ObjectInfoState.EdgeSlide,
- 0x80000102u);
+ expected = fresh.SetPosition(
+ PlacementRequest(
+ new Vector3(11f, 10f, 0f),
+ ObjectInfoState.EdgeSlide,
+ 0x80000102u));
+ actual = reused.SetPosition(
+ PlacementRequest(
+ new Vector3(11f, 10f, 0f),
+ ObjectInfoState.EdgeSlide,
+ 0x80000102u));
- AssertResolveBitwise(expected, actual, "placement after hostile identity");
+ AssertSetPositionBitwise(expected, actual, "placement after hostile identity");
+ Assert.True(
+ expected.IsCommitted,
+ "fresh engine did not commit the second placement");
+ Assert.True(
+ actual.IsCommitted,
+ "reused engine did not commit the second placement");
}
+ ///
+ /// Builds the two-sphere capsule request the legacy scalar
+ /// InitPath(sphereRadius: 0.48, sphereHeight: 1.835) reconstructed
+ /// internally (origin (0,0,radius) + (0,0,height-radius), both
+ /// radius-sized), so canonical SetPosition sees the identical
+ /// mover shape the legacy ResolvePlacement call did.
+ ///
+ private static PhysicsSetPositionRequest PlacementRequest(
+ Vector3 position,
+ ObjectInfoState moverFlags,
+ uint movingEntityId) => new(
+ Position: position,
+ Orientation: Quaternion.Identity,
+ CellId: Cell,
+ CellLocalPosition: position,
+ Spheres: ImmutableArray.Create(
+ new FlatCollisionSphere(new Vector3(0f, 0f, 0.48f), 0.48f),
+ new FlatCollisionSphere(new Vector3(0f, 0f, 1.835f - 0.48f), 0.48f)),
+ Scale: 1f,
+ StepUpHeight: 0.40f,
+ StepDownHeight: 0.40f,
+ MoverFlags: moverFlags,
+ MovingEntityId: movingEntityId,
+ Flags: PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Slide);
+
private static void RunSequence(
Func buildEngine,
params ResolveSpec[] specs)
@@ -441,6 +480,66 @@ public sealed class TransitionScratchDifferentialTests
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
}
+ ///
+ /// Bitwise sibling of for
+ /// , used by the re-pointed
+ /// (C5a). Every
+ /// field is compared — float-bearing members bitwise (a stale scratch
+ /// leaking a different -0.0/+0.0 sign bit would otherwise pass a
+ /// value-equality check), enum/bool/id members by value, and the
+ /// immutable id arrays by ordered sequence.
+ ///
+ private static void AssertSetPositionBitwise(
+ PhysicsSetPositionResult expected,
+ PhysicsSetPositionResult actual,
+ string context)
+ {
+ Assert.Equal(expected.Error, actual.Error);
+ Assert.Equal(expected.Residence, actual.Residence);
+ AssertVectorBitwise(expected.Position, actual.Position, context);
+ AssertQuaternionBitwise(expected.Orientation, actual.Orientation, context);
+ Assert.Equal(expected.CellId, actual.CellId);
+ AssertVectorBitwise(
+ expected.CellLocalPosition,
+ actual.CellLocalPosition,
+ $"{context}.CellLocalPosition");
+ Assert.Equal(expected.InContact, actual.InContact);
+ Assert.Equal(expected.OnWalkable, actual.OnWalkable);
+ AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context);
+ Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId);
+ Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
+ Assert.Equal(expected.SlidingNormalValid, actual.SlidingNormalValid);
+ AssertVectorBitwise(
+ expected.SlidingNormal,
+ actual.SlidingNormal,
+ $"{context}.SlidingNormal");
+ Assert.Equal(expected.CollisionNormalValid, actual.CollisionNormalValid);
+ AssertVectorBitwise(
+ expected.CollisionNormal,
+ actual.CollisionNormal,
+ $"{context}.CollisionNormal");
+ Assert.Equal(expected.FramesStationaryFall, actual.FramesStationaryFall);
+ Assert.Equal(expected.CollidedWithEnvironment, actual.CollidedWithEnvironment);
+ Assert.Equal(expected.CollisionHandlerResult, actual.CollisionHandlerResult);
+ Assert.Equal(expected.CellChanged, actual.CellChanged);
+ Assert.Equal(expected.ShadowAction, actual.ShadowAction);
+ // ImmutableArray.Equals(ImmutableArray) compares the BACKING
+ // ARRAY REFERENCE, not the elements — the fresh and reused engines
+ // never share a backing array even when the contents match, so
+ // Assert.Equal on the bare ImmutableArray would false-fail. Compare
+ // as plain arrays (regular array Equals/sequence comparison) instead.
+ Assert.Equal(ToArrayOrEmpty(expected.CrossCellIds), ToArrayOrEmpty(actual.CrossCellIds));
+ Assert.Equal(
+ ToArrayOrEmpty(expected.CollidedObjectIds),
+ ToArrayOrEmpty(actual.CollidedObjectIds));
+ Assert.Equal(
+ ToArrayOrEmpty(expected.QueriedCellIds),
+ ToArrayOrEmpty(actual.QueriedCellIds));
+ }
+
+ private static uint[] ToArrayOrEmpty(ImmutableArray array) =>
+ array.IsDefault ? Array.Empty() : array.ToArray();
+
private static void AssertCoverage(ResolveSpec spec, ResolveResult result)
{
switch (spec.Coverage)
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index 1a0ec5e9..77211094 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -402,9 +402,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType(
runtime.MovementOwner.Controller);
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
- 0xA9B40001u);
+ 0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@@ -533,9 +533,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType(
runtime.MovementOwner.Controller);
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
- 0xA9B40001u);
+ 0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@@ -727,9 +727,9 @@ public sealed class HeadlessSessionHostTests
PlayerMovementController controller =
Assert.IsType(
runtime.MovementOwner.Controller);
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
- 0xA9B40001u);
+ 0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@@ -878,9 +878,9 @@ public sealed class HeadlessSessionHostTests
Assert.IsType(
runtime.MovementOwner.Controller);
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(48f, 49f, 50f),
- 0xA9B40001u);
+ 0xA9B40001u, new Vector3(48f, 49f, 50f));
projection.ProjectPosition(
record,
isLocalPlayer: true,
@@ -1583,7 +1583,7 @@ public sealed class HeadlessSessionHostTests
record.Snapshot,
replaceGeneration: false));
var controller = new PlayerMovementController(engine);
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(96f, 97f, 50f),
0xA9B40001u,
new Vector3(96f, 97f, 50f));
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
index 449cd604..bb2b9078 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/LocalPlayerImmediatePositionTests.cs
@@ -94,7 +94,7 @@ public sealed class LocalPlayerImmediatePositionTests
worldOffsetX: 0f,
worldOffsetY: 0f);
var player = new PlayerMovementController(engine);
- player.SetPosition(
+ player.SeedPlacementForTest(
new Vector3(96f, 97f, 50f),
0xA9B40001u,
new Vector3(96f, 97f, 50f));
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
index da117431..85dfd8e5 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMouseLookMovementTests.cs
@@ -442,7 +442,7 @@ public sealed class PlayerMouseLookMovementTests
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001u, new Vector3(96f, 96f, 50f));
return controller;
}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index df499c04..30fdd1c3 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -53,7 +53,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var result = controller.Update(0.016f, new MovementInput());
@@ -89,7 +89,7 @@ public class PlayerMovementControllerTests
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var rest = new Vector3(96f, 96f, 50f);
- controller.SetPosition(rest, 0x0001);
+ controller.SeedPlacementForTest(rest, 0x0001, rest);
// Settle one frame so the resolver establishes its rest state, then
// capture the baseline the body must hold.
@@ -126,7 +126,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
// Walk forward ~0.5 s, then release.
@@ -157,7 +157,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f; // facing +X
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
@@ -181,7 +181,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((_, _) => { });
@@ -199,7 +199,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
controller.ObjectScale = 2f;
controller.AttachAnimationRootMotionSource((_, frame) =>
@@ -220,7 +220,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
@@ -247,7 +247,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f; // body local +Y faces world +X
controller.AttachAnimationRootMotionSource((_, frame) =>
{
@@ -273,7 +273,7 @@ public class PlayerMovementControllerTests
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
controller.SetBodyOrientation(initial);
@@ -297,7 +297,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
int sample = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
@@ -331,7 +331,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
@@ -356,8 +356,8 @@ public class PlayerMovementControllerTests
var combined = new PlayerMovementController(MakeFlatEngine());
var split = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- combined.SetPosition(start, 0x0001);
- split.SetPosition(start, 0x0001);
+ combined.SeedPlacementForTest(start, 0x0001, start);
+ split.SeedPlacementForTest(start, 0x0001, start);
int combinedHooks = 0;
int splitHooks = 0;
@@ -391,7 +391,7 @@ public class PlayerMovementControllerTests
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
int advances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
@@ -412,7 +412,7 @@ public class PlayerMovementControllerTests
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialTime = controller.SimTimeSeconds;
float initialYaw = controller.Yaw;
Vector3 initialPosition = controller.Position;
@@ -452,7 +452,7 @@ public class PlayerMovementControllerTests
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialTime = controller.SimTimeSeconds;
int targetPasses = 0;
@@ -475,7 +475,7 @@ public class PlayerMovementControllerTests
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Update(1f, new MovementInput(Jump: true));
Vector3 beforeRelease = controller.Position;
@@ -506,7 +506,7 @@ public class PlayerMovementControllerTests
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
@@ -531,7 +531,7 @@ public class PlayerMovementControllerTests
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
@@ -554,14 +554,14 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
var snapped = new Vector3(120f, 80f, 50f);
- controller.SetPosition(snapped, 0x0001);
+ controller.SeedPlacementForTest(snapped, 0x0001, snapped);
var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput());
Assert.Equal(snapped, result.Position);
@@ -572,7 +572,7 @@ public class PlayerMovementControllerTests
public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity;
@@ -616,7 +616,7 @@ public class PlayerMovementControllerTests
var world = new Vector3(12f, 12f, 50f);
var wireLocal = new Vector3(12f, 12f, 50f);
- controller.SetPosition(world, 0xA9B40031u, wireLocal);
+ controller.SeedPlacementForTest(world, 0xA9B40031u, wireLocal);
Assert.Equal(0xA9B40001u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
@@ -628,7 +628,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
int animationAdvances = 0;
int hookPasses = 0;
@@ -656,7 +656,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var result = controller.Update(
@@ -680,14 +680,14 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
@@ -701,7 +701,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
@@ -715,7 +715,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
@@ -732,7 +732,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Charged jump: hold for a full charge (1s dt), then release to fire.
// A full charge gives enough Vz that the player clears the 0.05-unit
@@ -749,7 +749,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Assert.True(controller.CanSendPositionEvent);
@@ -765,7 +765,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
Assert.Equal(default, controller.JumpCharge);
@@ -783,7 +783,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Charged jump: hold for a full charge, then release.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
@@ -829,7 +829,7 @@ public class PlayerMovementControllerTests
// One step at walk speed will cross into the low region where terrain drops
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(118f, 96f, 50f), 0x0001, new Vector3(118f, 96f, 50f));
controller.Yaw = 0f; // facing +X
// Single step — should trigger airborne state because terrain drops sharply.
@@ -915,7 +915,7 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // low16 < 0x0100 -> outdoor
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); // low16 < 0x0100 -> outdoor
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.True(cm.IsConstrained);
@@ -935,9 +935,9 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(10f, 10f, 5f),
- 0x01000105u); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
+ 0x01000105u, new Vector3(10f, 10f, 5f)); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.Equal(5.0f, cm.ConstraintDistanceStart);
@@ -948,7 +948,7 @@ public class PlayerMovementControllerTests
public void SetPosition_Teleport_TearsDownAndRearmsAPreviouslyFullyConstrainedLeash()
{
var (controller, _) = MakeControllerWithHost();
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
// Synthetically over-strain the leash with a tight band (production
@@ -958,7 +958,7 @@ public class PlayerMovementControllerTests
cm.AdjustOffset(new MotionDeltaFrame { Origin = new Vector3(5f, 0f, 0f) }, quantum: 0.1);
Assert.True(controller.PositionManager.IsFullyConstrained());
- controller.SetPosition(new Vector3(150f, 150f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(150f, 150f, 50f), 0x0001, new Vector3(150f, 150f, 50f));
// retail teleport_hook's UnConstrain, followed by the fresh re-arm at
// the new position, clears the stale over-strained state.
@@ -971,7 +971,7 @@ public class PlayerMovementControllerTests
{
var (controller, _) = MakeControllerWithHost();
var initial = new Vector3(96f, 96f, 50f);
- controller.SetPosition(initial, 0x0001);
+ controller.SeedPlacementForTest(initial, 0x0001, initial);
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocityBeforeCommit = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // sanity: actually moving
@@ -1002,7 +1002,7 @@ public class PlayerMovementControllerTests
public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
{
var (controller, _) = MakeControllerWithHost();
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 10f);
@@ -1034,7 +1034,7 @@ public class PlayerMovementControllerTests
public void Update_ConstraintOverstrained_PushesIsFullyConstrainedOntoBodyAndBlocksJump()
{
var (controller, _) = MakeControllerWithHost();
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 2f);
@@ -1069,7 +1069,7 @@ public class PlayerMovementControllerTests
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
- controller.SetPosition(start, 0x0001);
+ controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
// Fixed per-tick local-forward delta, matching
// Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale's
@@ -1145,7 +1145,7 @@ public class PlayerMovementControllerTests
default));
Assert.Throws(() => candidate.TickHidden(
1f / 60f));
- Assert.Throws(() => candidate.SetPosition(
+ Assert.Throws(() => candidate.SeedPlacementForTest(
Vector3.One,
0xA9B40021u,
Vector3.One));
@@ -1155,7 +1155,12 @@ public class PlayerMovementControllerTests
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws(() =>
candidate.NoteMovementSent(1f));
- Assert.Throws(candidate.CommitPreparedPosition);
+ // C5a (2026-08-05): CommitPreparedPosition is deleted (zero production
+ // callers); ArmConstraintLeashAtCommittedPlacement is its production
+ // replacement and carries the SAME EnsurePublishedForRuntimeOperation
+ // guard at its own entry, so this re-points rather than drops.
+ Assert.Throws(
+ candidate.ArmConstraintLeashAtCommittedPlacement);
Assert.Throws(() =>
candidate.ApplyPhysicsState(PhysicsStateFlags.Frozen));
Assert.Throws(() => candidate.LocalEntityId = 2u);
@@ -1194,7 +1199,7 @@ public class PlayerMovementControllerTests
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
- controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
+ controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
controller.Motion.DefaultSink = new FakeAnimationDispatchSink();
// No root-motion displacement contributed -- isolates the residual
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
index a4b0208f..9bde0d2e 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerOutboundPositionTests.cs
@@ -17,7 +17,7 @@ public sealed class PlayerOutboundPositionTests
// The render origin remains A9B1, so a point 22.56 m into A9B2 is
// represented at world Y=214.56. The wire frame must remain the
// carried A9B2-local 22.56, never the render-space 214.56.
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
@@ -42,7 +42,7 @@ public sealed class PlayerOutboundPositionTests
public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion()
{
var controller = new PlayerMovementController(new PhysicsEngine());
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(116.07f, 214.56f, 83.76f),
0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f));
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
index e4517127..1703f19c 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
@@ -13,7 +13,7 @@ public sealed class RuntimeLocalPlayerMovementStateTests
{
LocalEntityId = 0x50000001u,
};
- controller.SetPosition(
+ controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
@@ -140,8 +140,8 @@ public sealed class RuntimeLocalPlayerMovementStateTests
second.Controller = secondController;
first.Execute(RuntimeMovementCommand.ToggleRunLock);
- firstController.SetPosition(Vector3.One, 0xA9B40001u, Vector3.One);
- secondController.SetPosition(
+ firstController.SeedPlacementForTest(Vector3.One, 0xA9B40001u, Vector3.One);
+ secondController.SeedPlacementForTest(
new Vector3(2f),
0xA9B50001u,
new Vector3(2f));
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
index 5b336467..011b642a 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
@@ -2970,7 +2970,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
1f / 60f));
Assert.Throws(() =>
controller.SuspendObjectUpdate(1f / 60f));
- Assert.Throws(() => controller.SetPosition(
+ Assert.Throws(() => controller.SeedPlacementForTest(
Vector3.One,
Cell,
Vector3.One));
@@ -3003,8 +3003,12 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
controller.PrepareForAttackRequest());
Assert.Throws(() =>
controller.RequestPosture(MotionCommand.Ready));
+ // C5a (2026-08-05): CommitPreparedPosition is deleted (zero production
+ // callers); ArmConstraintLeashAtCommittedPlacement is its production
+ // replacement and carries the SAME EnsurePublishedForRuntimeOperation
+ // guard at its own entry, so this re-points rather than drops.
Assert.Throws(() =>
- controller.CommitPreparedPosition());
+ controller.ArmConstraintLeashAtCommittedPlacement());
Assert.Throws(() =>
controller.PreparePositionForCommit(Vector3.One, Cell, Vector3.One));
Assert.Throws(() =>