Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-09 23:18:52 +02:00
commit 217a4bad69
329 changed files with 81439 additions and 8499 deletions

8
.gitignore vendored
View file

@ -91,3 +91,11 @@ C[€-
# Junction to Claude Code per-project memory (Obsidian vault visibility)
claude-memory
studio-shots/
# MP1b acdream-bake output — user-machine artifact, never committed
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
*.pak
# session-local physics capture artifacts (worktree root)
/resolve-*.jsonl
/launch-*.log

View file

@ -1,7 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/AcDream.App/AcDream.App.csproj" />
<Project Path="src/AcDream.Bake/AcDream.Bake.csproj" />
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
@ -14,6 +16,8 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
<Project Path="tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj" />
<Project Path="tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj" />
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />

View file

@ -108,25 +108,18 @@ movement queries.
## Current state
**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER +
are navigable; **login into a dungeon** now loads + places the player and is
**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate,
`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker
acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`).
REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex
bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon
collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor
world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135).
M2 (CombatMath) deferred. Detail in ISSUES (#135#138) + the render/physics digests.
Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail
in the docs below, NOT here.
**⚠ Reconcile (banner stale as of 2026-06-22):** the active recent work stream has been the
parallel **D.2b retail-UI track** (inventory window / panels — the inventory empty-slot art
shipped + was visually confirmed this session; next is container-switching), NOT the M1.5
dungeon items above. See [`claude-memory/project_d2b_retail_ui.md`] + the handoff
`docs/research/2026-06-22-container-switching-handoff.md`. Decide in the milestones doc whether
M1.5 is paused or D.2b retail-UI is its own milestone, then rewrite this banner.
**Currently working toward: M1.5 — Indoor world feels right** (building/cellar demo
DONE + gated; **#137 dungeon collision CLOSED, A7 dungeon lighting #79/#93 CLOSED
2026-07-09** — two root causes fixed + user-reverified 2nd-floor case; REMAINING
critical path = **#138 teleport-OUT**). One **user-report-driven
parity track** still interleaves at the issue level (NOT a milestone; see the M1.5
note in the milestones doc): **D.2b retail UI** (next: container-switching —
`claude-memory/project_d2b_retail_ui.md`). The **R5 movement-manager arc is DONE**
(2026-07-05, V5 facade shipped; close-out banner in
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`; carried: #167, R6/TS-42).
**Track MP** (modern-pipeline perf side track, dedicated sessions only — roadmap
"Track MP") is at MP0. M2 (CombatMath) deferred. Keep this paragraph ≤6 lines + pointers — detail in the docs
below, NOT here.
For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
@ -958,6 +951,12 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
decisions at frame boundaries. Used to converge the U.4c flap fix
(root indoor visibility at player's cell, not eye).
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
per-armed-tick steer lines (signed gap dist, applied delta, heading
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND

File diff suppressed because it is too large Load diff

View file

@ -37,14 +37,13 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 17 rows
## 1. Intentional architecture (IA) — 16 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 |
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
@ -61,7 +60,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 2. Adaptation (AD) — 31 rows
## 2. Adaptation (AD) — 36 rows (AD-39/40/41 added 2026-07-07 — the #182 verbatim player-physics rebuild; AD-25 narrowed to the remote-DR sweep, its player half retired by the rebuild)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -71,8 +70,6 @@ accepted-divergence entries (#96, #49, #50).
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| 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 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
@ -88,17 +85,26 @@ accepted-divergence entries (#96, #49, #50).
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) |
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
| AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position |
| AD-38 | First camera frame seats sought = viewer = the full-length target eye (starts converged); retail hard resets both to the PLAYER's position (`set_viewer(pos, reset_sought=1)`) at login/teleport/cell-loss, so the retail camera visibly re-extends outward from the head over ~1 s | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_initialised` branch) | acdream teleports don't reset the camera object today; the pivot-anchored sweep self-heals in one convergence, and the login zoom-out is cosmetic. Identified (not introduced) during the #180 reading | Login/teleport first frames show a fully-extended boom where retail shows a zoom-out; if a teleport lands the stale sought behind distant geometry the first sweeps clip until convergence (sub-second) | `SmartBox::set_viewer` 0x00452c40 (reset_sought=1 sites pc:92775, pc:92886); retire by wiring teleport → sought reset |
| AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 |
| AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.App/Input/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) |
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
---
## 3. Documented approximation (AP) — 71 rows
## 3. Documented approximation (AP) — 82 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too; AP-89 added 2026-07-08 — #188 fading-wall TransparentPartHook multiplies sampled texture alpha, not a separate material alpha channel; AP-72/AP-73/AP-74 added 2026-07-03 — retail cursor OS-fallback, character-raise optimistic-apply w/o rollback ledger, UseDone WeenieError hardcoded text map; NOTE two unrelated rows both numbered AP-84 survive from separate sessions' merges — renumber one in a future cleanup pass)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -107,10 +113,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 |
| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 |
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
@ -124,14 +127,14 @@ accepted-divergence entries (#96, #49, #50).
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius |
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
@ -176,15 +179,29 @@ accepted-divergence entries (#96, #49, #50).
| AP-73 | **Character raises apply optimistically with no pending/rollback ledger** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits unassigned XP / skill credits locally (via eventful store APIs). Retail's local-predict behavior for raises is unverified; item moves in acdream reconcile via RecordPending/Confirm/Rollback but raises do not (**#163**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ApplyLocalRaise`) | The next server property echo (PlayerDescription / private stat updates) is authoritative and overwrites every optimistic value; ACE confirms affordable raises, and the affordability gate runs client-side first (`Cost <= 0` refuses). | A server-rejected raise (XP race, validation) leaves ranks/XP/credits wrong on the sheet until the next full property refresh — retire by porting the ledger (#163) or cdb-tracing retail's raise flow. | unverified — candidate cdb trace: retail raise-button send path |
---
## 4. Temporary stopgap (TS) — 32 rows
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
| AP-84 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port (2026-07-07, retires TS-45) reads `collide_with_point`'s PerfectClip time-of-impact math from ACE `Sphere.CollideWithPoint` + `FindTimeOfCollision` (ported as `FindSphereTimeOfCollision`) because the BN x87 mush is unreadable there. NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — `SetCollisionNormal` + Collided — is decomp-verified). Sibling of AP-83 (the cylinder family's identical dead branch) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint` PerfectClip branch + `FindSphereTimeOfCollision`; pseudocode doc `docs/research/2026-07-07-csphere-collision-family-pseudocode.md` §6/§8.2) | The load-bearing path (non-PerfectClip Collided; the family's step-up/slide/land) is decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the ACE-derived TOI may diverge from retail — clip-through / wrong deflection on sphere targets; re-decompile 0x00537230 in Ghidra before shipping missiles | `CSphere::collide_with_point` 0x00537230 (pc:321327); ACE Sphere.cs `CollideWithPoint` |
| AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody``ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal``remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|BodyLastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 translucency`, applied to all 4 D3D9 material alpha channels) |
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
@ -200,18 +217,28 @@ accepted-divergence entries (#96, #49, #50).
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-22 | `adjust_motion` not ported — backward (×0.65) / strafe (×1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) |
| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.App/Input/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) |
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | Numbered chat tabs (element ids `0x10000522``0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset``InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting``Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime``CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989`MovementManager::UseTime` @0x00515998`CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal rε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
---
@ -226,7 +253,6 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
@ -242,17 +268,15 @@ WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),

View file

@ -30,6 +30,68 @@ our tree (see CLAUDE.md for the full breakdown):
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold).
**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.**
The GL-free portion of the former `ObjectMeshManager` — dat read → polygon
walk → vertex/index build → inline BCn/palette texture decode →
`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/`
assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact
same extraction code offline without an OpenGL context. This was a
mechanical, verbatim move (namespace + visibility only) per
`docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md` — no
behavior change, no divergence-register row.
- `src/AcDream.Content/MeshExtractor.cs` — the `Prepare*` family
(`PrepareMeshData`, `PrepareSetupMeshData`, `PrepareGfxObjMeshData`,
`PrepareEnvCellMeshData`, `PrepareCellStructMeshData`,
`PrepareCellStructEdgeLineData`) + private helpers (`CollectParts`,
`CollectEmittersFromScript`, `ComputeBounds`, `BuildPolygonIndices`,
`BuildCellStructPolygonIndices`) and the decoded-texture cache /
`ThreadLocal<BcDecoder>` that back them.
- `src/AcDream.Content/ObjectMeshData.cs` — the CPU-side boundary records:
`VertexPositionNormalTexture`, `StagedEmitter`, `ObjectMeshData`,
`MeshBatchData`, `TextureBatchData`.
- `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of
the GL-owning `TextureAtlasManager` (which stays in App and now
references the lifted struct).
- `src/AcDream.Content/UploadFormats.cs` — Content-owned
`UploadPixelFormat`/`UploadPixelType` enums carried by
`MeshBatchData`/`TextureBatchData` instead of
`Silk.NET.OpenGL.PixelFormat`/`PixelType` (Content must stay
Silk.NET-free — the bake tool must not ship GL binaries). Underlying
values are the GL ABI constants, numerically identical to the Silk.NET
members; App casts at its single upload boundary (the `AddTexture` call
in `UploadGfxObjMeshData`) via a lifted nullable enum conversion —
value- and null-preserving.
- `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free
dependencies of the extractor, moved (namespace-only) alongside it.
- **Side-stage sink seam:** `CollectEmittersFromScript` pre-loads particle
GfxObj meshes mid-extraction and, pre-MP1a, enqueued them directly onto
`ObjectMeshManager._stagedMeshData`. The extractor now takes an
`Action<ObjectMeshData>? sideStagedSink` constructor parameter; App wires
it to `_stagedMeshData.Enqueue`, preserving the original
immediate-enqueue semantics exactly — including on a mid-`Prepare*`
throw (preloads staged before a malformed-dat texture-decode exception
survive, as they always did). The MP1b bake tool passes its own
collector.
- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a
thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle
and all GL upload — constructs one `MeshExtractor` and delegates every
former `Prepare*` call site to it), `ObjectRenderData`/`ObjectRenderBatch`
(hold a GL `TextureAtlasManager` field), `TextureAtlasManager`,
`DatCollectionAdapter` (concrete `DatCollection`-backed implementation of
the now-Content-namespaced `IDatReaderWriter`), `GeometryUtils` (used only
by App-side raycasting, not by extraction), `AcSurfaceMetadata`/
`AcSurfaceMetadataTable` (not on the extraction path), `Building.cs`
(explicitly out of scope).
- `AcDream.Core` is untouched; `AcDream.Content` references `AcDream.Core`
(for `TextureHelpers`, `Sphere`/`BoundingBox` via `Chorizite.Core.Lib`);
`AcDream.App` references `AcDream.Content`. `AcDream.Core` does NOT
reference `AcDream.Content` (one-way dependency, per Code Structure Rule 2).
- Reason: MP1 (`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`
§6.1) — the bake tool needs the identical mesh/texture extraction code
running with no GL context, so baked pak output and live-client output stay
byte-identical.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has

View file

@ -293,14 +293,53 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
**A7.L1 progress (2026-07-09, `d275ed55` + `9ebb2060`):** the Town Network
"fountain room too dark" report (#79/#93 umbrella) is root-caused to TWO
distinct bugs, both fixed and user-confirmed improved ("lightning is
better"): (1) `BuildPointLightSnapshot`'s player-nearest cap had no
visibility filter — in a 463+ fixture hub, wall-disconnected-but-Euclidean-
closer fixtures could out-rank a visible room's own torches; scoped
candidacy to last frame's rendered visible-cell set. (2) THE bigger fix: the
mesh-empty hydration gate dropped an entity's ENTIRE registration — Setup
Lights included — whenever its visual mesh flattened to zero parts (a
mesh-less "light attach point" carrying only a Light, common dat pattern);
fixed via `EntityHydrationRules.ShouldKeepEntity`. Dungeon-wide effect:
registered lights 498→892 on the same launch. **#93 + #80 CLOSED same
session** (user re-verified the 2nd-floor case directly). **#189 (missing
fountain particle) also CLOSED same session** — turned out to be a THIRD,
unrelated bug: #190, an interior-entity-id counter overflow the light fix
triggered as a side effect (widened 8→12 bits, `e651cb6d`). **#94
(held-item spotlight) stays open but BLOCKED** — acdream doesn't yet support
equipping hand-held items at all, so it's currently untestable; not an A7
blocker. Full detail in ISSUES.md #93/#189/#190/#94 (Recently closed +
Open sections) + AP-85 in the divergence register.
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
snapshot cap evicts in-range lights of visible cells (Hub has 366
fixtures), so per-cell 8-light sets churn as the camera moves.
Uncapping was live-tested and reverted because the full pool exposes the
per-cell-reach + fixture-curve defects below (through-floor light,
1/d-vs-1/d³). Analysis PRE-PAID — see
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
per-cell `insert_light` registration → static fixture curve → stripe
hunt → uncap) + register AP-85.
**Hypothesis layers (less mapped than physics):**
- Per-cell environment-light tag association — indoor cells should
inherit only their own env lights, not outdoor day-cycle.
- Light visibility culling — what lights actually contribute to each
cell's render.
cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
registration — lights are a flat world-space sphere-overlap pool that
reaches through solid floors; retail's `insert_light` 0x0054d1b0
scopes each light to its cell.**
- Per-entity light direction transform — held-item-spotlight bug
(#L-spotlight) is per-entity attribution gone wrong.
- Static-stab atmospheric inheritance (#81).
- **Fixture falloff curve (#176/#177): stationary server-spawned
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
**Investigation methodology:** less existing infrastructure than
physics. Requires:
@ -612,6 +651,33 @@ change must keep both phase plans in sync.
- **L.1b — Command router + motion-state cleanup.** Extract tested
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
and split death `Sanctuary` action from persistent `Dead` substate.
*(Partial, shipped 2026-06-30:* dual `IMotionCommandCatalog` seam —
`AceModernCommandCatalog` runtime default + full-extraction
`Retail2013CommandCatalog` conformance; deleted the blind 0x016E0x0197
override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug
as ISSUES #159**fixed 2026-07-04 `2de5a011`**, the constants now derive
directly from `DatReaderWriter.Enums.MotionCommand` by name.
Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)*
*(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` /
`apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and
unified the local velocity + keyboard turn + jump onto one input-built
`RawMotionState``get_state_velocity` — backward/strafe-left no longer zero,
strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from
interpreted `turn_speed`, hand-mirrored formulas deleted. User smoke sign-off
2026-07-01 (strafe-left moves + symmetric, backward outpaces strafe, jump
lateral, turn feel unchanged; no crash/rejection). Retired register
TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to
the retail-exact 3.11999989f. Pseudocode:
`docs/research/2026-07-01-d6-motion-interp-pseudocode.md`.)*
*(D6.2b, shipped 2026-07-01:* the wire now sends the retail-faithful RAW
`forward_speed=1.0` (omitted by default-difference packing) instead of runRate.
Echo-test settled the open question: ACE RECOMPUTES the broadcast run speed from
run skill and auto-upgrades WalkForward+HoldKey.Run → RunForward — sending 1.0
still broadcasts RunForward @ runRate, and a retail observer saw +Acdream run at
full pace. The stale `MovementData.cs`-citing "ACE relays" comment was
corrected. Wire + velocity are now both raw-1.0; threading them onto one shared
RawMotionState (removing section-6's duplicate build) is a no-op-behavior
cleanup follow-up.)
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
style transitions, attack swings by stance/power/height, hit reactions,
evades/blocks/parries, and death handoff.
@ -672,6 +738,88 @@ diagnostic scaffolding, not yet the final collision system.
tests to real-world fixtures and verify local acdream view plus retail
observer view. ACE accepting a position is a compatibility check, not proof
of fine-grained retail collision parity.
- **Phase R — retail motion & animation ground-up reconstruction (2026-07-02,
user mandate: total overhaul, verbatim, all entity classes, no frozen
code).** Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`
— stages R1 (CSequence) → R8 (cutover audit). SUPERSEDES the L.2g S3S6
slices below; L.2g S1/S2/S5 (shipped 2026-07-02: staleness gate, inbound
funnel + 183-case live-retail conformance harness, pace-guesser deletion)
are absorbed as components. NOTE: this Phase R is unrelated to the retired
"R1→R8 refactor sketch" from early 2026 — same letter, new plan.
**R1 SHIPPED 2026-07-02** (verbatim CSequence core + adapter rehost,
a987cad1 tail). **R2 SHIPPED 2026-07-02** (Q0Q6; Fix B/fast-path/
stop-anim-fallback/G17/RemoteMotionSink deleted, AP-73 retired).
**R3 SHIPPED 2026-07-03** (W0W7: pending_motions + MotionDone consumer,
jump family, ground transitions with K-fix18 DELETED, the ONE
DoInterpretedMotion + zero-tick flush, LOCAL PLAYER unification —
UpdatePlayerAnimation + the synthesis layer deleted; trail in the plan
doc). **R4 SHIPPED 2026-07-03** (V0V6: the verbatim MoveToManager, all
33 members + conformance harness; mt 6/7/8/9 wire completion; remote AND
local-player cutovers — RemoteMoveToDriver, PlanMoveToStart, and the
whole B.6 auto-walk DELETED; the P1 autonomous-echo gate ported verbatim;
TS-36 bound — input/jump/teleport cancel movetos through the retail
interrupt chain; retired AD-8/AD-9/AP-8/AP-9/AD-26/TS-36; trail in the
plan doc). R2+R3+R4 visual pass PASSED 2026-07-03 (#161#163 closed;
#164#166 filed). **R5-V1 SHIPPED 2026-07-03 `3d89446d`** — PositionManager
facade + StickyManager + ConstraintManager + the full TargetManager voyeur
system ported to Core, fully tested, UNWIRED (purely additive; renamed the
misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`);
ConstraintManager unarmed (issue #167, TS-35 stays). Decomp/ACE/plan:
`docs/research/2026-07-03-r5-managers/`. **R5-V2 SHIPPED 2026-07-03 `fffe90b3`**
— wired the voyeur system per-entity via `EntityPhysicsHost`, retired AP-79;
verified live (remote creatures chase). Same session fixed two PRE-EXISTING
streaming bugs surfaced while visual-gating V2 (the user's "world doesn't load"
report): **#168** pending-bucket trap (`315af02f`) + **#169** cold-spawn
streaming hole (`9b06a9b8`) — both verified live. Filed **#170** (remote
creature chase+attack animation divergence vs retail — glide/over-frequency/
uniform attacks; decomposes into #159 + pending_motions loop + dead-reckon
glide). **#170 CLOSED + gated 2026-07-04** (`427332ac` re-dispatch flood +
`1051fc83` armed-moveto-always-ticks). **R5-V3 SHIPPED + gated 2026-07-04**
(#171 sticky melee, three slices `5bd2b8bc`/`7a823176`/`69966950` — TS-39
retired, TS-43/TS-44/AP-82 filed). **R5-V4 behavioral items SHIPPED + gated
2026-07-04 `f423884b`** (head style-on-change all mt types, #164 autonomy
bit, mt-0 wire flags consumed). **R5-V5 SHIPPED 2026-07-05 — the R5 arc is
DONE**: the `MovementManager` facade (retail acclient.h `/* 3463 */`,
methods 0x00524000-0x00524790) now owns each entity's interp+moveto pair —
ONE per entity (`RemoteMotion.Movement`, `PlayerMovementController.Movement`,
chase-harness mirror); the three wiring sites construct through
`MoveToFactory` + `MakeMoveToManager` (0x00524000) and the relay shapes
(`UseTime` 0x005242f0, `HitGround` 0x00524300, `HandleExitWorld` 0x00524350,
`CancelMoveTo` 0x005241b0, `HandleUpdateTarget` 0x00524790,
`PerformMovement` 0x005240d0) replace the loose-pair call sites. Structural,
zero behavior change; 15 facade conformance tests; suite 4052 green with the
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out:
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
carried: #167 (ConstraintManager arming), TS-42 per-tick order (R6).
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
remote entities (players, NPCs, monsters — one funnel, user-approved):
`move_to_interpreted_state` (0x005289c0) + `apply_interpreted_movement`
(0x00528600) + `StopInterpretedMotion` stop path + `MotionTableManager`
bookkeeping (`remove_redundant_links` / `CheckForCompletedMotions` /
`MotionDone`), delete the non-retail UP-pace→cycle inference layer, restore
the walk↔run link pose. Slice order S0S6 + full deviation map (DEV-1..10,
adversarially verified): `docs/research/2026-07-02-inbound-motion-deviation-map.md`.
Acceptance: walk↔run toggle on an observed remote reacts at wire latency with
no compounding animation/position desync; no stop-slide; user visual
side-by-side vs retail observer.
**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):**
- **D1**`MoveToState` now packs `RawMotionState` by retail default-difference
(`RawMotionState::Pack` 0x0051ed10): a field bit is set only when the value
differs from its retail default, so over-sent `forwardSpeed=1.0` / `holdKey=None`
are gone. New `AcDream.Core.Physics.RawMotionState` data type + `RawMotionStatePacker`.
- **D3** — MoveToState trailing byte = `(standingLongjump?2:0)|(contact?1:0)`
(`MoveToStatePack::Pack` 0x005168f0); explicit `contact`/`standingLongjump` params.
- **D4**`JumpAction` rewritten to retail `JumpPack` (0x00516d10): extent · velocity ·
full Position · 4 timestamps; spurious objectGuid/spellId removed.
- **D5** — audited + confirmed divergent (MTS over-stamping vs retail `SendMovementEvent`);
left unchanged, recorded as register **TS-33**, deferred to a dedicated cadence-port slice.
- Golden-byte tests (Core.Net.Tests) for all packers; full suite green.
- ACE smoke + user visual sign-off 2026-07-01: login → run/turn/RunLock all
accepted (server echoes player motion, no rejection), NPCs animate incl.
inbound MoveTo type-7 + TurnToHeading type-9 (no crash), graceful exit.
- Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.
**Acceptance:**
- A developer can trace the active movement path: input/motion -> body
@ -1071,6 +1219,50 @@ port in any phase — no separate listing here.
---
### Track MP — Modern Pipeline (performance side track; user-commissioned 2026-07-05)
> **⏸ PARKED 2026-07-05 (user decision) — WINS BANKED, REMAINDER DEFERRED TO THE FUTURE.**
> The track's actual trigger — the FPS/ms counter *fluctuating wildly* (read by the
> user as inefficiency) — is **RESOLVED**: MP0 (frame profiler), MP1a (Content
> extraction), and **MP-Alloc safe batch** shipped and merged to local main
> (`093cdb6d`, suite 4188 green, user-confirmed steady FPS). **ECS (MP3) + the
> rest are DEFERRED**: ECS is the *throughput* lever (raise avg FPS ~230→300), a
> DIFFERENT goal from steadiness — revisit ONLY if the user wants a higher raw FPS
> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table
> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever),
> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over
~16 ms during any traversal), then 300+ FPS sustained in dense towns.
Perf-only per `feedback_render_perf_not_faithfulness_gated` — pixels and
game-feel stay identical; the simulation's retail-mirroring OO structure is
untouched. NOT part of M1.5 — runs in dedicated side-track sessions; M1.5
critical path wins every conflict.
**Sequencing note (2026-07-05, evidence-driven):** the steady-state allocation
triage (MP-Alloc) was **pulled forward ahead of the pak cutover (MP1c)** after a
54-site allocation audit proved the town GC churn is ~9095% OUTSIDE the MP3 draw
surface (so MP3 won't fix it) and independent of the pak — it's the felt-most-often
smoothness tax and mostly easy faithfulness-neutral buffer-reuse work. See
`project_mp_track_findings.md`. Load-time smoothness (the pak, MP1c) is the rarer
hitch and follows.
| Phase | What | Status |
|---|---|---|
| MP0 | Honest frame profiler (`[frame-prof]`) + baseline capture | ✅ SHIPPED 2026-07-05 — gate PASSED (`docs/research/2026-07-05-mp0-baseline.md`); headline: town spikes = GC churn (1.53 MB/frame), teleport hitch 211 ms |
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
| MP1b | Pak format + `acdream-bake` CLI + mmap zero-copy `PakReader` + equivalence test | 🔵 code + review-fixes done; **full-bake gate found 865 GB → EnvCell dedup slice REQUIRED before shipped** (spec §6.6) |
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc |
| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ |
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
---
## Cross-cutting work tracked in parallel
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.

View file

@ -225,6 +225,40 @@ synthesis workarounds were removed by A6.P4. Remaining feel-level debt is
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
lighting fidelity not yet done — folded forward).
**Interleaved parity tracks (reconciled 2026-07-04 — NOT milestones).** Two
work streams have run alongside M1.5, both driven by the user's live
side-by-side-vs-retail sessions, both issue-level per the operating rules
(observed defects → ISSUES → fix → user gate), and both stay subordinate
to M1.5 rather than becoming milestones of their own: (1) **D.2b retail
UI** — inventory window / paperdoll / item interaction shipped and gated;
next container-switching (`claude-memory/project_d2b_retail_ui.md`);
(2) **the R5 movement-manager arc — DONE 2026-07-05** — the retail
MovementManager family port (TargetManager voyeur system V2,
StickyManager/PositionManager V3, V4 behavioral items, V5 MovementManager
facade — every entity now holds ONE `MovementManager` owning its
interp+moveto pair) that closed the #170 chase-slide and #171 pack-melee
gates (`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` has the
arc close-out). Rationale:
rule 1's "one active milestone" governs where NEW scope goes — these
tracks fix observed retail-parity defects in already-shipped systems,
which the operating rules have always allowed; declaring them milestones
would manufacture scope where a tactical ledger (ISSUES + handoffs)
already carries them.
**Modern Pipeline side track (Track MP — user-commissioned 2026-07-05; NOT
a milestone, NOT part of M1.5).** A performance modernization arc (baked
asset pipeline → retail distance-degrade → ECS render world with delta
submission → zero-alloc frame loop) targeting smoothness first, then 300+
FPS in dense towns. Spec:
`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`; phase table
in the roadmap ("Track MP"). **Freeze exception (explicit, user-authorized
2026-07-05):** this track may reopen the otherwise-frozen streaming and
WB-rendering subsystems — but only under its own gated phases (pixel- and
feel-identical conformance gates, legacy path deleted at each gate), never
as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in
dedicated side-track sessions; the M1.5 critical path wins every conflict,
and MP4 is hard-queued behind #137.
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
don't work: the streaming/load/render/physics pipeline was built entirely
around outdoor landblocks (terrain + scattered buildings) and has no path

View file

@ -0,0 +1,174 @@
# Phase R — retail motion & animation stack, ground-up reconstruction
Date: 2026-07-02. **Mandate (user, verbatim intent):** a complete new
movement + animation system, verbatim retail equivalent — all movement,
inbound and outbound, all animation, for players, NPCs, and monsters. No
frozen code, no bandaids, no guessing, no approximation. Total overhaul.
**Execution shape:** a staged verbatim RECONSTRUCTION, not a big-bang
cutover — each stage builds a retail class 1:1 with a conformance harness,
cuts consumers over, and DELETES the legacy path in the same stage. "New
system" is the destination; the client keeps running between stages. The
cdb-golden technique is proven (183/183 live-retail dispatch conformance in
S2a) and is the acceptance mechanism for every stage: the user's eyes are a
final sanity pass only.
Supersedes the L.2g S3S6 slice plan (S1/S2/S5 landed and are absorbed as
components). This doc is the plan of record; the deviation map
(`docs/research/2026-07-02-inbound-motion-deviation-map.md`), the funnel
pseudocode (`…-s2-inbound-funnel-pseudocode.md`), and the 2026-06-04
sequencer deep-dive are its research base.
## The retail module map to reconstruct (per physics object, ALL classes)
```
CPhysicsObj (local player, remote player, NPC, monster — ONE pipeline)
├─ MovementManager unpack_movement 0x00524440 (10-way dispatch),
│ │ PerformMovement, MotionDone relay
│ ├─ CMotionInterp raw_state + interpreted_state + pending_motions,
│ │ DoMotion / DoInterpretedMotion / StopInterpretedMotion,
│ │ apply_raw/interpreted/current_movement, my_run_rate,
│ │ HitGround / LeaveGround / ReportExhaustion, jump family
│ └─ MoveToManager movement types 6/7/8/9 (MoveToObject/Position,
│ TurnToObject/Heading), node stepping, arrival radii,
│ fail distance, CanCharge walk/run selection
├─ CPartArray → MotionTableManager pending_animations, add_to_queue 0x0051bfe0,
│ │ remove_redundant_links 0x0051bf20,
│ │ CheckForCompletedMotions 0x0051be00 → AnimationDone
│ └─ CMotionTable::GetObjectSequence 0x00522860
│ │ same-substate fast path (change_cycle_speed +
│ │ subtract/combine_motion), link path (get_link +
│ │ style-default double-hop), re_modify, is_allowed
│ └─ CSequence anim-node DLList, append_animation 0x00525510,
│ remove_cyclic_anims, clear_physics, velocity/omega
│ accumulators, update/update_internal + apply_physics
│ (root motion), placement frames, hook dispatch
├─ PositionManager InterpolationManager [PORTED ✓ L.3] + StickyManager
│ + ConstraintManager
└─ per-tick UpdateObjectInternal 0x005156b0 order:
CPartArray.Update (CSequence root motion) → PositionManager.adjust_offset
(chase REPLACES) → Frame.combine → UpdatePhysicsInternal → FULL transition
sweep (ALL entities — retires the remote no-sweep fork) → MovementManager.UseTime
/ CPartArray.HandleMovement / PositionManager.UseTime → process_hooks
```
## Already-verbatim components (absorbed, not rewritten)
- `MotionSequenceGate` (S1) — is_newer 3-stamp gate, live-validated.
- Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` /
`DispatchInterpretedMotion` / verbatim `contact_allows_move` (S2a) +
183-case live-trace conformance suite.
- `InterpolationManager` (L.3) — chase constants re-verified 2026-07-02.
- Outbound: `RawMotionState::Pack` default-difference, MoveToStatePack
trailer, JumpPack (L.2b/D6.2b, golden-byte tests); dual command catalogs
(L.1b); `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/
`get_state_velocity` (D6).
Everything else is reconstruction scope — especially: `AnimationSequencer`
internals (becomes the CSequence port's host or is replaced by it), the
three per-tick drive paths in `GameWindow.TickAnimations`, `RemoteMotionSink`
(temporary S2b seam — dissolves into GetObjectSequence), `RemoteMoveToDriver`,
`ServerControlledLocomotion`, the motion half of `PlayerMovementController`,
the 300 ms stop-detection window, NPC UP hard-snaps.
## Stage plan (each: pseudocode → harness → port → cutover → DELETE legacy → register sweep)
- **R1 — CSequence verbatim. SHIPPED 2026-07-02** (commits 1371c2a1 P0/P1, 778744bf P2, 5138b8fb P3, 658b91d8 P4, 9147344a P5, +P6): the verbatim core (AnimSequenceNode/CSequence/FrameOps, 56 conformance tests) + the AnimationSequencer adapter rehost deleting the legacy epsilon/stale-head/safety-cap/per-node-flag mechanisms; root motion flows through Advance(dt, Frame) = retail update(Frame*). Registers AD-33/AD-34. Node list, framerate/rate math, velocity+
omega accumulators (set/combine/subtract), update/update_internal root
motion, apply_physics, placement frames, hook dispatch. Goldens: dat
MotionData fixtures + a cdb trace of append_animation/remove_cyclic_anims
args (script pattern: tools/cdb/l2g-observer.cdb). Cutover: becomes the
sequencer core behind the existing AnimationSequencer API, then the API
narrows to retail's.
- **R2 — GetObjectSequence + MotionTableManager. SHIPPED 2026-07-02 (pending
the stage visual pass).** Fast path,
link path (restores the walk↔run link pose — old S4), re_modify (modifier
blend — retires AP-73), pending_animations + remove_redundant_links +
CheckForCompletedMotions → AnimationDone→MotionDone chain (old S3).
RemoteMotionSink's single-cycle pick DELETED — GetObjectSequence decides.
Progress: Q0 pins (dc54a3e4) + Q1 MotionState (2345da30), Q2 CMotionTable all-4-branch
GetObjectSequence + statics (98f58db9, 44 tests), Q3 MotionTableManager+
pending_animations (aa65990a, 47 tests), Q4 adapter cutover (3b9d9bb6 —
SetCycle/PlayAction → PerformMovement; Fix B / fast-path / stop-anim
fallback / G17 gate DELETED; 11-scenario trace conformance a6235a36).
Q5 RemoteMotionSink DELETED (d82f07d4 — funnel → MotionTableDispatchSink →
PerformMovement; AP-73 retired; spawn/despawn run initialize_state/
HandleExitWorld; live smoke green: MOTIONDONE chain firing in-world).
Q6 sweep done with Q5. OUTSTANDING: the ONE user visual pass (walk↔run
stride continuity, turn-while-running legs, emote overlay, stop settle).
R3 prep done in parallel: research base 8eff3978 + W0 pins cd0289be (all
10 ambiguities resolved, adversarially verified).
- **R3 — CMotionInterp completion. SHIPPED 2026-07-03 (pending the stage
visual pass).** pending_motions/MotionDone, DoMotion, jump family
(jump_charge_is_allowed/motion_allows_jump verbatim — the misattribution
found in S2a), HitGround/LeaveGround/ReportExhaustion, enter_default_state.
LOCAL PLAYER unified (edge-driven CommandInterpreter altitude; the
synthesis layer + UpdatePlayerAnimation deleted).
Trail: W-1 research 8eff3978, W0 pins cd0289be (A1-A10 adversarially
verified), W1 state completion 86649591, W2 pending_motions 37167991,
W3 jump family af476444, W4 ground transitions + K-fix18 DELETED
e214acdf, W5 one-DoInterpretedMotion + zero-tick flush df7b096d
(discovery: the dispatch RESULT gates queue+state writes — sink returns
bool), W6 local-player unification fb7beb70 (map fc5a2cda; discoveries:
ChargeJump never wired, CurrentHoldKey shadow-field staleness, the
autonomous-flag clobber). Registers through the arc: retired AP-73/
AP-74/AP-78/TS-34/IA-4 + the Fix-B-class inventions; added AD-36
(narrowed), AP-75/76/77, TS-35/36/37/38. EXPECTED-DIFFS for the visual
pass: #45 sidestep factor + ANIM_SPEED_SCALE retired (local now matches
remotes), auto-walk-at-run walk-pace legs (R4), ApplyServerRunRate echo
live through fast re-speed. R3+R2 share ONE visual pass.
- **R4 — MoveToManager verbatim. SHIPPED 2026-07-03 (pending the stage
visual pass).** Types 6/7/8/9 (TurnToObject/TurnToHeading — the dropped
D9/DEV-5 commands), node stepping, arrival, fail distance, CanCharge.
RemoteMoveToDriver + ServerControlledLocomotion.PlanMoveToStart + B.6
auto-walk all DELETED.
Trail: research base 988304e1, V0 pins 386b1ce5 (P1-P7 resolved; P1
autonomous-echo gate + P3 heading_diff mirror adversarially sealed, P3
down to instruction bytes), V1 command-selection family e0d2492c
(GetCommand + CanCharge fast-path + MoveToMath), V2 the verbatim manager
addc8e97 (all 33 members, 101 tests, seam-injected harness), V3 wire
completion a144e873 (mt 8/9 parse + full params exposure + sticky
trailer), V4 remote cutover 7016b26c (per-remote manager, P4
TargetTracker adapter, retail unpack dispatch; retired AD-8/AD-9/AP-8/
AP-9; smoke log verified clean), V5 local-player cutover b3decdfa (P1
gate ported verbatim — ACE's autonomous echo dropped before unpack;
B.6 deleted; TS-36 bound — input/jump/teleport cancel through the retail
interrupt chain; run-rate re-anchored to PD skills + mt-6/7 my_run_rate,
echo tap deleted with NO AD row; MoveToComplete client seam widened to
natural completion for AD-27; adversarial-review fixes: remote HitGround
relay, mt-8 wire_heading degrade, remote curTime clock), V6 register/
docs sweep (AD-34 widened with the MoveToNode rename, NEW TS-39 StickTo/
Unstick no-op seams → R5, TS-33 extended with the orientation-diff gap,
TS-21/AD-25/AP-24/AP-30 re-anchored). EXPECTED-DIFFS for the visual
pass: melee-range stop distance (retail cylinder distance — the AD-8
max() class is gone), auto-walk legs now walk/run per CanCharge with
real turn cycles during corrections, walk-pace close-in demote.
OUTSTANDING: the stage visual pass (folds into the pending R2+R3 pass).
- **R5 — MovementManager + MovementSystem facade.** One per-object pipeline
for every entity class; StickyManager (stick_to_object — the motionFlags
0x100 bit) + ConstraintManager ports; GameWindow's OnLiveMotionUpdated
shrinks to parse→MovementSystem.HandleMovementEvent.
- **R6 — per-tick UpdateObjectInternal order.** Retail tick order for ALL
entities incl. the transition sweep for remotes (retires the L.3-M2
no-sweep fork + the path A/B split + the 300 ms stop window + NPC UP
hard-snap special cases). GameWindow.TickAnimations sheds its motion
logic entirely (Code Structure Rule 1).
- **R7 — outbound autonomy cadence.** ShouldSendPositionEvent (0x006b45e0)
+ the MTS/AP stamp split — retires TS-33. Outbound becomes 100% verbatim.
- **R8 — cutover audit.** grep-sweep for legacy motion code, register
reconciliation (every AD/AP/TS motion row retired or re-justified), full
live protocol (walk/run/toggle/turn/circle/stop/jump/MoveTo/TurnTo,
player+NPC+monster), ONE final user visual pass.
## Standing rules for every stage
1. Decomp is the oracle; ACE the interpretation aid; cdb/TTD the runtime
arbiter when they disagree (the S0/S2 pattern).
2. No stage ships without its conformance fixtures. Golden sources: dat
tables, cdb traces (both observer + actor side), captured wire logs.
3. Every commit: build + full suite green; register rows added/retired in
the same commit; roadmap stage table updated on stage completion.
4. New code lives in `src/AcDream.Core/Physics/Motion/` (pure logic; GL-free)
with App seams only for rendering handoff (Structure Rule 2).
5. Delete, don't gate: when a stage cuts over, the legacy path is REMOVED in
that stage (mandate: no frozen code, no bandaids).

View file

@ -0,0 +1,151 @@
# ACE vs 2013 Retail Motion Command Gap
Date: 2026-06-26
## Why this matters
The movement/animation parity work cannot use one command catalog blindly.
The 2013 `acclient.exe` decomp has a `command_ids[0x198]` table and a matching command-name table, but ACE's `MotionCommand` enum is based on a later client catalog. The local DAT motion tables also contain later-client command keys. If AcDream reconstructs incoming ACE wire commands through the 2013 table only, some server-triggered animations will disappear.
The concrete example is lifestone recall:
- 2013 retail decomp names `LifestoneRecall` as `0x10000150`.
- Current ACE names `LifestoneRecall` as `0x10000153`.
- Local DAT `MotionTable` links include both values, but many later recall/offhand commands only exist under the ACE-shifted value.
## Sources checked
- 2013 retail decomp:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `command_ids[0x198]` at `0x007c73e8`
- command-name table around `0x008041ec..0x0080444c`
- ACE current master:
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/MotionCommand.cs`
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/CommandMasks.cs`
- Local DATs:
- `C:\Users\erikn\Documents\Asheron's Call\client_portal.dat`
- scanned 436 `MotionTable` records with `Chorizite.DatReaderWriter 2.1.7`
- Current AcDream resolver:
- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
## Catalog mismatch
Parsed inventory:
- 2013 retail command names parsed: `406`
- ACE command names parsed: `409`
- Common names: `400`
- Common names with different values: `130`
The important mismatch is a contiguous low-word `+3` shift beginning after the targeting UI block:
| Name | 2013 retail | ACE current |
|---|---:|---:|
| `SnowAngelState` | `0x43000115` | `0x43000118` |
| `MeditateState` | `0x43000119` | `0x4300011C` |
| `Pickup5` | `0x40000133` | `0x40000136` |
| `HouseRecall` | `0x10000137` | `0x1000013A` |
| `SitState` | `0x4300013A` | `0x4300013D` |
| `HaveASeat` | `0x1300014F` | `0x13000152` |
| `LifestoneRecall` | `0x10000150` | `0x10000153` |
| `MarketplaceRecall` | `0x10000163` | `0x10000166` |
| `AllegianceHometownRecall` | `0x1000016E` | `0x10000171` |
| `PKArenaRecall` | `0x1000016F` | `0x10000172` |
| `OffhandSlashHigh` | `0x10000170` | `0x10000173` |
ACE's enum comments show the branch point:
- `SkillHealSelf = 0x1000010e`
- `SkillHealOther = 0x1000010f`
- duplicate/commented legacy slots around `0x010f..0x0111`
- `SnowAngelState = 0x43000118`
2013 retail instead has:
- `SkillHealSelf = 0x0900010E`
- `NextMonster = 0x0900010F`
- `PreviousMonster = 0x09000110`
- `ClosestMonster = 0x09000111`
- `NextPlayer = 0x09000112`
- `PreviousPlayer = 0x09000113`
- `ClosestPlayer = 0x09000114`
- `SnowAngelState = 0x43000115`
So this is a later-client catalog divergence, not just a single bad enum value.
## MotionTable availability
I scanned all 436 local DAT `MotionTable` records for the selected 2013 and ACE values. Counts below are link target hits; recall/action commands are stored in `Links`, not `Cycles`.
| Command | 2013 value hits | ACE value hits | Interpretation |
|---|---:|---:|---|
| `HouseRecall` | `317` | `24` | both exist; the old value is very common |
| `LifestoneRecall` | `28` | `19` | both exist; ACE `/ls` value can animate |
| `MarketplaceRecall` | `0` | `19` | only ACE-shifted value exists |
| `AllegianceHometownRecall` | `0` | `19` | only ACE-shifted value exists |
| `PKArenaRecall` | `0` | `18` | only ACE-shifted value exists |
| `OffhandSlashHigh` | `0` | `31` | only ACE-shifted value exists |
This means the local DATs are not pure 2013-command-table data. They include later-client action keys that match ACE/DatReaderWriter.
## Current AcDream resolver behavior
Current `MotionCommandResolver.ReconstructFullCommand` results:
| Wire low | Current full command |
|---:|---:|
| `0x0137` | `0x40000137` |
| `0x013A` | `0x1000013A` |
| `0x0150` | `0x13000150` |
| `0x0153` | `0x10000153` |
| `0x0163` | `0x09000163` |
| `0x0166` | `0x10000166` |
| `0x016E` | `0x1000016E` |
| `0x016F` | `0x1000016F` |
| `0x0170` | `0x10000170` |
| `0x0171` | `0x10000171` |
| `0x0172` | `0x10000172` |
| `0x0173` | `0x10000173` |
Implications:
- ACE `/ls` wire `0x0153` currently reconstructs to `0x10000153`, which is good for ACE and local DATs.
- 2013 retail lifestone wire `0x0150` would currently reconstruct as `ScanHorizon` chat emote, not `LifestoneRecall`.
- The override comment in `MotionCommandResolver` is wrong: the mismatch does not start at `AllegianceHometownRecall`; it starts earlier around `SnowAngelState`.
- The override range `0x016E..0x0197` maps `0x016E/0x016F/0x0170` to action-class commands even though ACE names those low words as UI commands. This probably does not hurt normal ACE animation broadcasts, but it is not a clean catalog model.
## Recommendation
Do not replace ACE/DatReaderWriter reconstruction with 2013 `command_ids` globally.
Use two catalogs explicitly:
1. `Retail2013CommandCatalog`
- For proving decomp behavior and reproducing the 2013 client command table.
- Useful for old-client conformance tests.
2. `AceModernCommandCatalog`
- For reconstructing ACE `InterpretedMotionState` wire `u16` commands into the full 32-bit motion commands that match local DAT motion tables.
- Should be the runtime default while talking to ACE.
Then add a resolver mode/test matrix:
- ACE mode:
- `0x0153 -> 0x10000153` (`LifestoneRecall`)
- `0x0166 -> 0x10000166` (`MarketplaceRecall`)
- `0x0171 -> 0x10000171` (`AllegianceHometownRecall`)
- `0x0173 -> 0x10000173` (`OffhandSlashHigh`)
- 2013 retail mode:
- `0x0150 -> 0x10000150` (`LifestoneRecall`)
- `0x0163 -> 0x10000163` (`MarketplaceRecall`)
- `0x016E -> 0x1000016E` (`AllegianceHometownRecall`)
- `0x0170 -> 0x10000170` (`OffhandSlashHigh`)
Animation lookup should be tested against the DAT motion tables too, not just enum names. A command is visually usable only if the entity's `MotionTable` has a `Links` or `Modifiers` entry for that full command.
## Bottom line
For ACE interop, the lifestone recall animation is not missing from the local DAT motion tables. It is missing only if we force ACE's wire `0x0153` through the 2013 retail command catalog.
The gap is real and broader than lifestone recall: the later-client/ACE command catalog diverges from the 2013 decomp for 130 common names, and several important animations only exist under the ACE-shifted IDs in the local DATs.

View file

@ -0,0 +1,301 @@
# Movement and Animation Retail Parity Audit
Date: 2026-06-26
## Verdict on approach
The approach is good if it stays decomp-first and test-first. For this area, "verbatim" needs to mean:
- Outbound wire bytes match retail packers for the same input state.
- Incoming movement packets are parsed into the same semantic state retail would unpack.
- Local, remote player, monster, NPC, and force-walk paths flow through retail-equivalent motion state transitions before animation selection.
- Any behavior that cannot be proven from retail decomp, Ghidra, or retail captures is marked unresolved instead of tuned by feel.
The approach is not good if it begins as a broad rewrite or animation polish pass. The current code has several comments and tests that already encode approximations. Those need to become failing parity tests first, then implementation slices.
## Sources checked
Primary oracle:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `docs/research/named-retail/acclient.h`
- `docs/research/named-retail/acclient.c`
Secondary oracle:
- `docs/research/acclient_decompiled.c`
- Ghidra HTTP bridge verified on `http://127.0.0.1:8081`; `methods?limit=3` responded and decompile-by-address worked during the audit. Codex tool discovery did not expose a first-class Ghidra MCP namespace in this thread, so direct HTTP is the usable path here.
Current implementation surfaces:
- `src/AcDream.Core.Net/Messages/MoveToState.cs`
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`
- `src/AcDream.Core.Net/Messages/JumpAction.cs`
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs`
- `src/AcDream.Core.Net/Messages/CreateObject.cs`
- `src/AcDream.Core.Net/WorldSession.cs`
- `src/AcDream.Core/Physics/MotionInterpreter.cs`
- `src/AcDream.Core/Physics/AnimationSequencer.cs`
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs`
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`
- `src/AcDream.App/Input/PlayerMovementController.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
## Retail source map
### Outbound movement to ACE
- `CommandInterpreter::ShouldSendPositionEvent` at `0x006b45e0`, pseudo line around `700233`.
- `CommandInterpreter::SendMovementEvent` at `0x006b4680`, pseudo line around `700274`.
- `CommandInterpreter::SendPositionEvent` at `0x006b4770`, pseudo line around `700316`.
- `CM_Movement::Event_AutonomousPosition` at `0x006af790`, sub-opcode `0xf753`.
- `CM_Movement::Event_Jump` at `0x006afa70`, sub-opcode `0xf61b`.
- `CM_Movement::Event_MoveToState` at `0x006afc00`, sub-opcode `0xf61c`.
- `MoveToStatePack::Pack` at `0x005168f0`, pseudo line around `284694`.
- `AutonomousPositionPack::Pack` at `0x00516af0`, pseudo line around `284795`.
- `JumpPack::Pack` at `0x00516d10`, pseudo line around `284915`.
- `RawMotionState::Pack` at `0x0051ed10`, pseudo line around `293761`.
Retail `RawMotionState` defaults:
- `current_holdkey = HoldKey.None`
- `current_style = 0x8000003d`
- `forward_command = 0x41000003`
- `forward_holdkey = HoldKey.Invalid`
- `forward_speed = 1.0`
- `sidestep_command = 0`
- `sidestep_holdkey = HoldKey.Invalid`
- `sidestep_speed = 1.0`
- `turn_command = 0`
- `turn_holdkey = HoldKey.Invalid`
- `turn_speed = 1.0`
Retail `RawMotionState::Pack` compares against these defaults. It does not set a field bit merely because the caller supplied a value. For example, `forward_speed == 1.0f` is omitted.
### Incoming movement and force movement
- `MovementManager::PerformMovement` at `0x005240d0`, pseudo line around `300194`, routes movement types `1-5` to `CMotionInterp` and `6-9` to `MoveToManager`.
- `MovementManager::unpack_movement` at `0x00524440`, pseudo line around `300563`, unpacks interpreted motion plus `MoveToObject`, `MoveToPosition`, `TurnToObject`, and `TurnToHeading`.
- `MovementParameters::UnPackNet` at `0x0052ac70`, pseudo line around `308118`.
- `MovementParameters::get_command` at `0x0052aa00`, pseudo line around `307946`.
- `MoveToManager::_DoMotion`, `MoveToPosition`, and related queue logic are around pseudo lines `306351`, `307187`, and `307521`.
Retail movement types from `acclient.h`:
- `0`: interpreted motion in inbound packet context
- `1`: raw motion
- `2`: interpreted motion
- `3`: stop raw motion
- `4`: stop interpreted motion
- `5`: stop completely
- `6`: move to object
- `7`: move to position
- `8`: turn to object
- `9`: turn to heading
### Animation and motion interpretation
- `CMotionInterp::apply_run_to_command` at `0x00527be0`, pseudo line around `305062`.
- `CMotionInterp::get_state_velocity` at `0x00527d50`.
- `CMotionInterp::adjust_motion` at `0x00528010`, pseudo line around `305343`.
- `CMotionInterp::apply_raw_movement` at `0x005287e0`.
- `CMotionInterp::DoInterpretedMotion` around pseudo line `305575`.
- `CMotionInterp::apply_current_movement` around pseudo line `305713`.
- `CMotionTable::GetObjectSequence` around pseudo line `298636`.
- `CSequence` append/advance/update logic around pseudo lines `301622`, `301777`, `301839`, and `302425`.
Key retail normalization:
- `SideStepLeft` becomes `SideStepRight` with negative speed.
- `SideStepRight` speed becomes `(3.11999989 / 1.25) * 0.5 * speed`.
- `TurnLeft` becomes `TurnRight` with negative speed.
- `WalkBackward` becomes `WalkForward` with `-0.649999976 * speed`.
- Hold-key `Run` upgrades forward walk to run, multiplies turn speed by `1.5`, and multiplies sidestep by run rate with an absolute clamp of `3.0`.
## Confirmed divergences
### D1: MoveToState raw flags are not retail
Current `MoveToState.Build` sets raw-motion flags from nullable/presence inputs. Retail sets flags by comparing the complete `RawMotionState` against default values. This means the current client can over-send:
- `CurrentHoldKey = None`
- `ForwardSpeed = 1.0`
- `SidestepSpeed = 1.0`
- `TurnSpeed = 1.0`
- likely per-axis hold keys when they are default `Invalid`
Existing `MoveToStateTests.Build_WalkForward_IncludesForwardCommandInFlags` currently asserts the non-retail behavior by expecting a `ForwardSpeed` flag for speed `1.0`.
Impact: outbound movement bytes can differ from retail for normal running, walking, sidestepping, and turning.
### D2: RawMotionState action-list and style packing are incomplete
Retail bitfield layout uses 11 one-bit flags plus `num_actions : 5` at bits `11-15`. Current comments imply the command list length is broader than retail. Current builder does not cover full action-list packing or current-style scenarios.
Impact: action/modifier movement states cannot be proven retail-equivalent.
### D3: MoveToState longjump bit is not modeled
Retail `MoveToStatePack::Pack` writes one trailing byte:
- bit `0x01`: contact
- bit `0x02`: `standing_longjump`
Current `GameWindow` passes only contact `0/1` into a generic byte. The source of `standing_longjump` is not wired as a named state.
Impact: standing longjump movement-state updates can differ from retail.
### D4: JumpAction packet layout is retail-incompatible
Retail `JumpPack` packs:
- `float extent`
- `Vector3 velocity`
- full `Position`
- four `u16` update timestamps
- alignment
Current `JumpAction.Build` packs extent and velocity, then timestamps, then extra `objectGuid` and `spellId`, and does not pack `Position`.
Impact: local jump movement update is a high-confidence wire mismatch.
### D5: Position heartbeat is close but not fully proven
Retail `ShouldSendPositionEvent` gates autonomous position updates by active state, autonomy level, player smartbox, 1.0 second interval, cell/frame change, and contact-plane change. Current `PlayerMovementController` has a similar cadence.
Open proof point: retail `SendMovementEvent` visibly stamps `last_sent_position_time`; retail `SendPositionEvent` stamps time, position, and contact plane. Current `GameWindow` calls `NotePositionSent` after both MTS and AP, stamping all three.
Impact: the client may suppress or reorder autonomous position sends differently after movement-state sends.
### D6: MotionInterpreter lacks canonical retail raw-to-interpreted normalization
Retail `CMotionInterp::apply_raw_movement` copies raw state, calls `adjust_motion` for forward/sidestep/turn, applies run-hold behavior, then applies interpreted movement. Current `MotionInterpreter.DoMotion` stores commands directly and applies velocity directly.
Specific visible risks:
- `SideStepLeft` may produce no velocity because velocity code only recognizes `SideStepRight`.
- backward and sidestep speeds are patched elsewhere instead of normalized at the retail source point.
- turn run-speed scaling is not faithfully represented.
- local jump lateral velocity relies on comments that say this is temporary until `adjust_motion` is ported.
Impact: running, slow walking, slow side walking, backward movement, turning, and jump lateral motion are not retail-proven.
### D7: Animation application is split away from retail motion flow
Retail movement application sequences style, forward or falling, sidestep start/stop, turn start/stop, and actions through `CMotionInterp` and `CMotionTable::GetObjectSequence`. Current `AnimationSequencer` implements useful pieces, but `MotionInterpreter.apply_current_movement` is mostly velocity-oriented and does not drive animation state through the same retail order.
Impact: animations can look plausible while being state-machine divergent.
### D8: Force-walk and MoveTo are approximations
Retail `MoveToManager` is a queued command system over `CMotionInterp`, with pre-turn, move, aux turn, final heading, sticky targeting, progress failure, and `MovementParameters::get_command` walk/run/hold-key selection.
Current `ServerControlledLocomotion`, `RemoteMoveToDriver`, and `PlayerMovementController.BeginServerAutoWalk` approximate visible steering and cycle selection.
Impact: ACE force walking and monster/NPC MoveTo behavior are not retail-equivalent.
### D9: Inbound movement types 8 and 9 are dropped
Retail inbound movement supports:
- `8`: `TurnToObject`
- `9`: `TurnToHeading`
Current `UpdateMotion` handles interpreted state and MoveTo types `6/7`, but not `8/9`.
Impact: incoming server turn commands for characters, monsters, and NPCs are ignored.
### D10: Spawn-time movement state is weaker than live movement state
`CreateObject` can detect movement types and some flags, but it does not preserve full MoveTo target/origin/threshold data the way live `UpdateMotion` does.
Impact: monsters/NPCs spawned while already moving cannot be driven retail-equivalently until a later movement packet arrives.
### D11: Sequence/autonomy data is parsed then discarded
Retail carries movement sequence, server-control sequence, autonomous state, motion flags, and position sequence into movement application. Current events expose only a subset.
Impact: ordering, stale update rejection, and force-control transitions cannot match retail.
### D12: Jump/falling/contact gates are simplified
Retail allows specific movement while falling/dead and has separate jump checks for posture, stamina, constraints, pending motion, contact, and leave/hit-ground reapplication. Current code blocks or simplifies several of these paths.
Impact: airborne movement, falling animation, dead/fallen movement commands, and jump eligibility are not retail-proven.
## Priority plan
### Phase 0: Lock the oracle
1. Create small retail-reference helpers/tests that encode `RawMotionState::Pack`, `MoveToStatePack::Pack`, `AutonomousPositionPack::Pack`, `JumpPack::Pack`, `MovementParameters::UnPackNet`, and `MovementParameters::get_command`.
2. Every helper must cite the decomp function and address in the test name or comments.
3. Any unclear branch gets a TODO with address and blocked status, not an approximation.
Exit criteria: failing tests describe current divergences without changing runtime behavior yet.
### Phase 1: Fix outbound wire parity
1. Change `MoveToState` packing from presence-based to retail default-difference packing.
2. Add explicit raw-state model with defaults, current style, action list count, and per-axis hold keys.
3. Add explicit `contact` and `standingLongjump` parameters for MTS trailing byte.
4. Replace `JumpAction` with retail `JumpPack`: extent, velocity, full position, four timestamps, align; remove extra object/spell fields unless another retail opcode proves they belong elsewhere.
5. Verify timestamp order: instance `[8]`, server-control `[5]`, teleport `[4]`, force-position `[6]`.
6. Audit whether MTS should stamp only last-send time or also last-send position/contact plane.
Exit criteria: golden byte tests pass for walk, run, sidestep, turn, slow walk/toggle-run, jump, contact, longjump, and AP heartbeat cases.
### Phase 2: Port retail raw/interpreted motion core
1. Introduce retail-shaped `RawMotionState` and `InterpretedMotionState` in core physics.
2. Port `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, and `apply_raw_movement`.
3. Route local player input through this path instead of compensating in `PlayerMovementController`.
4. Preserve run rate and hold-key semantics, including toggle-run slow-walk behavior.
Exit criteria: movement state tests prove backward, sidestep left/right, run turn, run sidestep clamp, forward run, and slow-walk semantics match retail math.
### Phase 3: Reconnect animation to retail movement application
1. Port enough of `CMotionInterp::apply_current_movement` to sequence style, forward/falling, sidestep stop/start, turn stop/start, and actions in retail order.
2. Keep existing `AnimationSequencer` where it already matches retail, but move normalization out of sequencer-only code and into motion interpretation.
3. Add parity tests for reverse playback, link fallback, modifier state, action sequencing, and root-motion composition using real retail motion table data.
Exit criteria: local and remote animation selection flows from the same interpreted state that drives velocity.
### Phase 4: Port inbound movement and force-walk behavior
1. Extend `UpdateMotion` to parse and surface types `8` and `9`.
2. Preserve sequence/autonomy/motion flags through `WorldSession` events.
3. Preserve full spawn-time MoveTo state in `CreateObject`.
4. Replace approximate server-driven locomotion with a retail-shaped `MoveToManager` queue over `CMotionInterp`.
5. Port `MovementParameters::get_command`, including walk/run threshold, `can_run`, force-walk, hold-key application, fail distance, sticky/target following, and final heading.
Exit criteria: incoming interpreted motion, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading, and ACE force-walk packets animate and move through retail-equivalent state transitions.
### Phase 5: Integration captures and regression guard
1. Capture retail-equivalent fixtures for normal run, slow walk, sidestep, slow sidestep, backward, turn, jump, forced walk, NPC MoveTo, monster chase/flee, and remote player interpolation.
2. Assert wire bytes for outbound messages.
3. Assert parsed semantic state for inbound messages.
4. Assert animation command sequence and velocity for local and remote entities.
5. Run full test suite plus a targeted in-app smoke pass.
Exit criteria: no movement or animation path is accepted without a retail-addressed test.
## Initial test inventory to add or replace
- `MoveToState` golden tests: default raw state, walk forward speed `1.0`, run forward, backward, sidestep right/left, turn right/left, current style, action count, contact/longjump byte.
- `JumpAction` golden tests: retail `JumpPack` layout with full position and no object/spell fields.
- `AutonomousPosition` tests: timestamp order and contact byte.
- `PlayerMovementController` tests: forward walk/run, slow walk/toggle-run, backward, sidestep, turn-only run, jump release, server auto-walk suppression.
- `ShouldSendPositionEvent` tests: pre-interval cell change, pre-interval contact-plane change, interval frame change, idle no-send, airborne AP suppression.
- `UpdateMotion` tests: interpreted, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading.
- `CreateObject` tests: spawn-time MoveTo preservation.
- `WorldSession` tests: sequence/autonomy propagation.
- `MotionInterpreter` tests: `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, contact gates, falling fallback, jump eligibility.
- `AnimationSequencer` tests: retail sequence order, stop/start transitions, reverse playback, link fallback, modifier combine/subtract behavior.
- `MoveToManager` tests: pre-turn, move, aux turn, final heading, target following, fail distance, force-walk walk/run choice.
## Implementation rule
No code path in this system should remain justified by "feels like retail", "ACE-compatible", or "close enough". If the decomp is unclear, use Ghidra or captures to resolve it. If it still cannot be resolved, leave the behavior behind a clearly named unresolved test or blocked TODO rather than guessing.

View file

@ -0,0 +1,227 @@
# D6 — CMotionInterp motion-normalization port: pseudocode + integration map
Date: 2026-07-01
Phase: **L.1 / L.2b follow-up (D6)** — port retail's raw→interpreted motion
normalization so local velocity for backward/strafe-left comes from the retail
source instead of hand-mirrored controller code.
Standard: **decomp-verbatim.** Retail decomp is the oracle; ACE
`MotionInterp.cs` is the secondary oracle and was confirmed to match retail
**byte-for-byte** on every constant and branch (understand-phase workflow,
2026-07-01).
Oracle anchors (all in `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `CMotionInterp::adjust_motion``0x00528010`, lines 305343-305400
- `CMotionInterp::apply_run_to_command``0x00527be0`, lines 305062-305123
- `CMotionInterp::get_state_velocity``0x00527d50`, lines 305160-305204
- `CMotionInterp::apply_raw_movement``0x005287e0`, lines 305817-305834
Secondary oracle: `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`
(constants L26-32; `adjust_motion` L394-428; `apply_run_to_command` L525-562;
`get_state_velocity` L678-700; `get_leave_ground_velocity` L654-663;
`apply_raw_movement` L506-523).
## Constants (retail = ACE, verified)
| name | value | retail anchor |
|---|---|---|
| BackwardsFactor | `0.649999976` | `007c8910`; WalkBackwards speed `*= -BackwardsFactor` |
| WalkAnimSpeed | `3.11999989` | `007c891c`; forward `v.Y = WalkAnimSpeed * fwdSpeed` |
| RunAnimSpeed | `4.0` | RunForward `v.Y`; also `maxSpeed = RunAnimSpeed * rate` |
| SidestepAnimSpeed | `1.25` | sidestep `v.X`; adjust_motion divisor |
| SidestepFactor | `0.5` | adjust_motion sidestep scale |
| RunTurnFactor | `1.5` | apply_run_to_command TurnRight |
| MaxSidestepAnimRate | `3.0` | apply_run_to_command SideStepRight clamp |
MotionCommand hex: `0x45000005`=WalkForward, `0x45000006`=WalkBackwards,
`0x44000007`=RunForward, `0x6500000d`=TurnRight, `0x6500000e`=TurnLeft,
`0x6500000f`=SideStepRight, `0x65000010`=SideStepLeft.
## The four functions (faithful pseudocode)
### apply_raw_movement (orchestrator) — 0x005287e0
```
if physics_obj == null: return
// copy 7 raw fields into interpreted
interp.current_style = raw.current_style
interp.forward_command = raw.forward_command ; interp.forward_speed = raw.forward_speed
interp.sidestep_command = raw.sidestep_command; interp.sidestep_speed = raw.sidestep_speed
interp.turn_command = raw.turn_command ; interp.turn_speed = raw.turn_speed
// normalize EACH channel with ITS OWN per-channel hold key
adjust_motion(ref interp.forward_command, ref interp.forward_speed, raw.forward_holdkey)
adjust_motion(ref interp.sidestep_command, ref interp.sidestep_speed, raw.sidestep_holdkey)
adjust_motion(ref interp.turn_command, ref interp.turn_speed, raw.turn_holdkey)
apply_interpreted_movement(arg2, arg3) // acdream: apply velocity via get_state_velocity
```
### adjust_motion(ref cmd, ref speed, holdKey) — 0x00528010
```
if weenie != null and !weenie.IsCreature(): return // non-creature: no adjust
switch cmd:
RunForward: return // already normalized — NO holdkey path
WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor // -0.649999976
TurnLeft: cmd = TurnRight; speed *= -1
SideStepLeft: cmd = SideStepRight; speed *= -1
// after remap, sidestep anim-rate scale:
if cmd == SideStepRight:
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // (3.12/1.25)*0.5 ≈ 1.24799995
// hold-key (runs for everything EXCEPT the RunForward early-return):
if holdKey == Invalid: holdKey = raw.current_holdkey
if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
```
GOTCHA: RunForward early-returns and does NOT run the hold-key path. The
sidestep negate happens BEFORE the ×1.248 scale (so SideStepLeft = -1.248*speed).
### apply_run_to_command(ref cmd, ref speed) — 0x00527be0
```
speedMod = 1.0
if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
switch cmd:
WalkForward:
if speed > 0: cmd = RunForward // promote ONLY when moving forward (sign gate)
speed *= speedMod // UNCONDITIONAL — applies to backward too (negative speed)
TurnRight:
speed *= RunTurnFactor // *1.5
SideStepRight:
speed *= speedMod
if abs(speed) > MaxSidestepAnimRate: // clamp to ±3.0 (ACE resolves the {test ah,0x5} polarity)
speed = speed > 0 ? MaxSidestepAnimRate : -MaxSidestepAnimRate
```
GOTCHA: WalkForward `speed *= speedMod` is unconditional — so **backward
DOES get run-scaled** (resolves the UN-5 "backward cites nothing" doubt in
acdream's favor). The walk→run promotion is sign-gated: backward keeps a
negative speed so it stays WalkForward (not promoted).
### get_state_velocity() -> local Vector3 — 0x00527d50
```
v = (0,0,0)
if interp.sidestep_command == SideStepRight: v.X = SidestepAnimSpeed * interp.sidestep_speed // 1.25*s
if interp.forward_command == WalkForward: v.Y = WalkAnimSpeed * interp.forward_speed // 3.12*s
else if interp.forward_command == RunForward: v.Y = RunAnimSpeed * interp.forward_speed // 4.0*s
v.Z = 0
rate = MyRunRate; if weenie != null: weenie.InqRunRate(ref rate)
maxSpeed = RunAnimSpeed * rate // 4.0 * rate
if v.Length() > maxSpeed: v = normalize(v) * maxSpeed // PORT ACE'S FORM — decomp operand
// names are wrong (x87 register aliasing)
```
GOTCHA: backward/strafe-left produce velocity here ONLY because adjust_motion
already converted them to WalkForward/SideStepRight with negated speed. Z (jump)
is added by the caller `get_leave_ground_velocity`, AFTER this clamp.
## acdream current state (what D6 replaces)
Two divergent velocity paths (verified in source):
- **PATH A**`MotionInterpreter.get_state_velocity` (`MotionInterpreter.cs:599-648`)
is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0
for WalkBackward + SideStepLeft because `adjust_motion` is **not ported** (the
InterpretedState holds the un-normalized command).
- **PATH B** — the controller bypass (`PlayerMovementController.cs`): grounded
velocity block builds body-local velocity by hand — forward `localY=stateVel.Y`
(`:986`), backward `localY=-(WalkAnimSpeed*0.65*runMul)` (`:988`), strafe
`localX=±SidestepAnimSpeed*runMul` (`:994/:996`); `runMul = InqRunRate if Run
else 1.0` (`:981-983`). The **same formulas are re-copied in the jump block**
(`:1072`, `:1076-1079`) because LeaveGround→get_state_velocity would zero
backward/strafe. This duplication is register rows **TS-22** + **UN-5**.
Integration seams:
- Input enters at `PlayerMovementController.Update(dt, MovementInput)` (`:838`);
section 2 (`:911-1000`) maps input → `_motion.DoMotion`/`DoInterpretedMotion`
+ the hand-built velocity.
- Velocity is consumed by `_body.set_local_velocity` (`:998`, jump `:1090`) →
physics integration (`:1096-1130`) → `ResolveWithTransition` (`:1136-1160`).
- The **outbound wire** (section 6, `:1329-1399` → MovementResult → GameWindow
`:8264-8388`) is a SEPARATE construction (the L.2b RawMotionState); it sends
the RAW commands (WalkForward+HoldKey.Run, backward/strafe @1.0) and ACE/the
observer runs adjust_motion. D6 must NOT change these wire bytes.
## Behavior changes D6 introduces (retail-faithful; flag for smoke test)
1. **Strafe ~20% faster.** Current `1.25×runRate`; retail `1.25 × 1.248
(adjust_motion sidestep scale) × runRate = 1.56×runRate`, then clamped via
`SideStepSpeed ≤ 3.0` (so v.X ≤ 3.75). acdream is currently missing the 1.248
scale + the 3.0 clamp.
2. **Sidestep ±3.0 clamp becomes active** at high run rates.
3. **Backward unchanged** for typical run rates (already retail-faithful:
`3.12×0.65×runRate`), now sourced from the real pipeline.
4. **Backward/strafe-left no longer zero** out of `get_state_velocity` (the
TS-22 bug class is retired at the source, not hand-patched).
## Out of scope for D6 (follow-ups)
- **Turn**: `get_state_velocity` does not produce turn velocity (turn is
angular). acdream drives Yaw directly (`RemoteMoveToDriver.TurnRateFor`,
~90°/135° per sec). D6 keeps direct-Yaw turn; routing turn through interpreted
angular velocity is a separate slice. `adjust_motion` on the turn channel is
still ported (for completeness + the wire), but it does not change turn feel.
- **RawMotionState unification**: retail uses ONE raw state for both the wire
and the velocity pipeline. D6 keeps the L.2b wire construction separate and
builds/normalizes a velocity-side raw state; unifying the two is a later slice.
- `apply_current_movement` full sequence (style/forward/sidestep/turn/action
ordering, `CMotionTable::GetObjectSequence`) — the understand-phase extractor
for this one hit the structured-output cap; re-extract when the animation
sequencing slice needs it. Not required for the velocity port.
## Design decisions (locked with the user 2026-07-01)
1. **Full unification.** ONE `AcDream.Core.Physics.RawMotionState` (the L.2b
type) is built from `MovementInput` and drives BOTH the outbound wire packing
AND the local velocity/turn pipeline (`apply_raw_movement`
`get_state_velocity` + turn omega). The separate GameWindow wire-construction
is retired; `MovementResult` carries the raw state, GameWindow packs it.
2. **`forward_speed = 1.0` on run — CONFIRMED viable (echo-test 2026-07-01).**
The echo-test settled it: acdream sends `forward_speed=1.0`, ACE broadcasts
back `RunForward @ runRate` (not `1.0`), and a retail observer saw +Acdream run
at full pace — so **ACE recomputes the broadcast run speed from run skill**
(the `MovementData.cs`-citing "ACE relays" assumption was wrong). The wire now
sends the retail-faithful raw `1.0` (D6.2b). Original reasoning below stands:
The raw `forward_speed`
must be `1.0` when running, because `apply_run_to_command` multiplies by
run-rate — carrying `runRate` in the raw state would double-scale to
`runRate²`. So the wire now sends `forward_speed=1.0` (omitted by
default-difference) + `HoldKey.Run`, NOT the current `runRate`. This is the
retail-faithful encoding (observer/server derives the run speed). **Acceptance
= the smoke echo:** send `1.0`, confirm the `UM` echo still shows the player
at `~runRate` (⇒ ACE recomputes from run skill ⇒ correct). If the echo shows
`~1.0`, ACE relays literally and the wire half reverts. The L.2b golden tests
update to the faithful encoding (forward_speed omitted on run).
3. **Turn ported to interpreted, feel unchanged.** Local turn is driven from the
interpreted `turn_speed` (`adjust_motion`: TurnLeft→TurnRight `×-1`, `×1.5`
run) via `omega.Z = ±(π/2) × turn_speed` — the SAME formula the remote path
already uses (`GameWindow.cs:4845`). Numerically identical to today's
`TurnRateFor` for the local player (both `π/2 × RunTurnFactor`), so no
turn-feel change; the fixed `TurnRateFor` direct-Yaw is replaced by the
pipeline. **AP-9 stays** (the `π/2` base rate is still an approximation of the
per-creature TurnSpeed; wiring that is a separate follow-up).
4. **References caveat.** Only `references/WorldBuilder` is checked out in this
worktree; ACE/holtburger are not. The retail decomp
(`docs/research/named-retail/`) is the sole in-repo oracle and was verified
directly for all four functions. ACE values quoted here are training-memory
cross-checks that happen to match the decomp — do not cite ACE file:lines.
**Retires / touches:** register **TS-22** deleted (adjust_motion ported;
get_state_velocity no longer returns 0 for backward/strafe-left). **UN-5**
resolved (run-scaling backward IS retail — `apply_run_to_command` unconditional
`*speedMod`). **AP-9** stays (turn base rate). A wire-encoding change row is
added if the echo test confirms `forward_speed=1.0`.
**Implementation slices:**
- **D6.1 (delegated, bounded):** port `adjust_motion` + `apply_run_to_command`
into `MotionInterpreter` + conformance tests, tests-first, against this doc.
- **D6.2 (lead, delicate):** `apply_raw_movement` orchestrator + build the
unified `RawMotionState` in `PlayerMovementController` from `MovementInput`,
route grounded + jump velocity through `get_state_velocity`, drive turn omega
from interpreted `turn_speed`, thread the raw state through `MovementResult`
to the GameWindow packer, delete the hand-mirrors (`:988/:994/:996` + jump
`:1072/:1076-1079`) and the `TurnRateFor` direct-Yaw, update the L.2b golden
tests. Then smoke (echo + strafe/backward/turn/jump feel).
## Conformance-test targets
- `adjust_motion`: each of the 7 command transforms + the sidestep 1.248 scale +
the backward -0.65 + the negate-before-scale order + the RunForward no-op +
the non-creature early return + per-channel holdKey inheritance.
- `apply_run_to_command`: WalkForward promote-when-speed>0 + unconditional
`*speedMod`; TurnRight ×1.5; SideStepRight ×speedMod + ±3.0 clamp (test the
boundary at runRate that pushes |speed| past 3.0).
- `get_state_velocity`: backward + strafe-left now non-zero (the TS-22 fix);
the maxSpeed normalize clamp; strafe = 1.56×runRate clamped to ≤3.75.
- `apply_raw_movement`: 3-channel copy + per-channel adjust + the run-promotion
of a running-forward raw state.

View file

@ -0,0 +1,323 @@
# Inbound motion deviation map — remote-entity animation + position vs retail
Date: 2026-07-02
Mode: **/investigate report — no code changes made.** This doc + the four mapper
reports under `2026-07-02-inbound-motion-maps/` are the only writes (uncommitted).
Branch: `claude/vigorous-joliot-f0c3ad`. Follows the handoff
`2026-07-02-inbound-motion-verbatim-port-handoff.md`.
## Symptom (acceptance oracle — user's live observation, axiom)
Watching a remote player in acdream: walk↔run transitions WITHOUT stopping react
too slowly in the motion interpreter, throwing animation + position off afterward
and compounding through continued running/turning; plus general sliding + position
errors on stop.
## Headline findings
1. **The premise behind acdream's current walk↔run mechanism is refuted.** The
#39-era UP-velocity refinement layer (`ApplyPlayerLocomotionRefinement`,
0.2 s UM-grace + 510 Hz UP cadence + 5.5/4.5 m/s hysteresis) was built on the
2026-05-06 finding "the wire goes silent on Shift-toggle." Both oracles now
contradict that finding:
- **Retail sends.** `CommandInterpreter`'s HoldRun command handler
(`0x85000001`, pseudo-C `acclient_2013_pseudo_c.txt:699322-699328`, addr
0x006b37a8→0x006b3852) calls `SetHoldRun` then `SendMovementEvent()`
**whenever the player is not standing still** — i.e. a Shift toggle while W
is held emits a fresh MoveToState. (Same for HoldSidestep `0x85000002`.)
- **ACE relays.** `GameActionMoveToState.cs:36` calls
`BroadcastMovement(moveToState)` **unconditionally** for every inbound
MoveToState (`Player_Networking.cs:364-365``GameMessageUpdateMotion`
broadcast, with the WalkForward+HoldKey.Run→RunForward upgrade and
run-skill speed recompute).
So an observer **should** receive a UM with ForwardCommand flipping
Walk↔Run on every Shift toggle. Why the 2026-05-06 clean test saw only
Ready↔Run `[FWD_WIRE]` transitions is the one open empirical question —
settle it with probe S0 below before touching the refinement layer.
2. **Retail never adapts animation from observed pace.** Three independent decomp
dives + the ACE port cross-check all converge: the inbound position path
(0xF748/0xF619 → `SmartBox::HandleReceivedPosition``MoveOrTeleport`
`InterpolationManager` chase) has **zero writes into `CMotionInterp`** — it
only *reads* `get_adjusted_max_speed()` (×2.0) as the chase-speed cap. The
wire's velocity field is a **dead parameter** in `MoveOrTeleport`
(pseudo-C:284304 — passed, never dereferenced). Animation changes come only
from motion events (0xF74C → `MovementManager::unpack_movement`).
acdream's pace→cycle inference layer has **no retail equivalent** (DEV-2)
and is an **unregistered deviation**.
3. **acdream's instant DR-velocity snap on walk↔run is retail-correct; the
pose hard-cut is the real deviation.** Adversarial verification refuted the
initial "retail paces velocity through the link clip" claim: in
`GetObjectSequence`'s link-transition branch, `add_motion(new cycle)` is the
LAST synchronous call and `CSequence::set_velocity` is a hard overwrite —
the sequence velocity snaps to the new cycle's value the same tick
(pseudo-C:298437-298468, 300798-300814; ACE `MotionTable.cs:144-168`,
`Sequence.cs:127-130`). What acdream's Fix B loses is only the link **pose**
(plus its authored root-motion frames): retail plays the authored
walk↔run link clip to completion before the new cycle's animation starts.
4. **Retail's remote pipeline is one atomic funnel; acdream's is three loosely
coupled writes.** That structural split (DEV-1) is what lets every gap
(DEV-2/3/5/6) desync pose from velocity with nothing to reconcile them.
## The retail inbound model (what "verbatim" means here)
Two decoupled inbound paths, meeting only at the per-tick frame combiner:
**Motion events (0xF74C / 0xF619-embedded)** —
`ACSmartBox::DispatchSmartBoxEvent` (pseudo-C:357117) →
`CPhysics::SetObjectMovement` (271370, staleness-gated on `update_times[8]`) →
`MovementManager::unpack_movement` (300563, **10-way jump table**: case 0 =
InterpretedMotionState, 6/7 = MoveTo, 8/9 = TurnToObject/TurnToHeading) →
`CMotionInterp::move_to_interpreted_state` (305936: flat `copy_movement_from`
overwrite of `interpreted_state`, caches `my_run_rate` from RunForward's
forward_speed) → `apply_interpreted_movement` (305713: per-slot
`DoInterpretedMotion`/`StopInterpretedMotion` dispatch in retail order — style,
forward, sidestep-or-stop, turn-or-stop, idle-stop enqueue) →
`CMotionTable::GetObjectSequence` (298636), which decides:
- **same substate + same speed sign** → fast path: `change_cycle_speed`
(playback rate × newSpeed/oldSpeed) + `subtract_motion`/`combine_motion`
velocity swap — same node keeps playing, **no restart** (acdream's SCFAST
fast-path is the equivalent);
- **substate change (walk↔run!)** → link path: `get_link` (double-hop through
the style default when no direct link / sign flip), `clear_physics` +
`remove_cyclic_anims` (keeps a mid-playback link, drops the old loop),
`add_motion(link)` + `add_motion(cycle)`, `re_modify` re-applies active
modifiers. Velocity = the **cycle's** (last overwrite, instant); pose = link
first, then cycle.
Bookkeeping: `MotionTableManager::add_to_queue`/`remove_redundant_links`/
`CheckForCompletedMotions` (290645-290854) + `CMotionInterp::pending_motions`/
`MotionDone` (305032/305238) reconcile completed commands at anim boundaries.
**Position events (0xF748/0xF619)** — `SmartBox::HandleReceivedPosition`
(92896): for remotes, `MoveOrTeleport` (284304) only — staleness-gated
(`POSITION_TS`/`TELEPORT_TS`), >96 units from player → plain snap, else
`InterpolationManager::InterpolateTo` node queue (cap 20). Per tick,
`adjust_offset` (353071) moves the full remaining distance capped at
`2 × get_adjusted_max_speed()` (fallback 7.5 m/s), stall detection (<30%
progress per 5 frames → fail counter >3 → hard snap). **acdream's
`InterpolationManager` is already a verbatim port of this (L.3).**
**Per tick** (`UpdateObjectInternal` 283611): `CPartArray::Update`
`CSequence::update` fills the frame delta from **the animation frames' baked
per-frame deltas** (anim-node path) or `velocity×dt` (`apply_physics` fallback);
`PositionManager::adjust_offset` **overwrites** it when a chase node is active
(last-writer-wins, NOT additive — matches acdream's `ComputeOffset` REPLACE
dichotomy); then the **full collision sweep** (`transition`) runs for remotes
too (acdream deliberately skips it for grounded player remotes — DEV-10).
**Stop** — one path for every stop: `CMotionTable::StopSequenceMotion` (298954)
re-enters `GetObjectSequence` targeting the style default with force=1:
`clear_physics` zeroes velocity/omega **at transition start**, the idle-link's
authored deceleration plays, `add_to_queue(READY)` + `RemoveMotion` bookkeeping.
Modifier stop = `subtract_motion` only. No pace-derived stop inference.
## Ranked deviation map
All 10 deviations were adversarially verified through two lenses (refute vs
current acdream code / refute vs retail decomp+ACE). 9 CONFIRMED, 1 REFUTED
(DEV-4's velocity half — corrected below). Full verify evidence:
`…/tasks/waqdgyk0m.output` (session temp) — the load-bearing citations were
additionally re-read first-hand in this session.
| # | Sev | Deviation | Symptom link |
|---|-----|-----------|--------------|
| DEV-1 | HIGH | Remote inbound bypasses the CMotionInterp funnel: bulk-copy + direct `SetCycle` instead of `move_to_interpreted_state`/`apply_interpreted_movement` | Structural root: pose + DR velocity are separate writes; every gap desyncs them with no reconciliation |
| DEV-2 | HIGH | Non-retail grace-window + hysteresis pace→cycle inference for player remotes (`UmGraceSeconds=0.2`, promote 5.5 / demote 4.5 m/s) | The direct "reacts too slowly": ≥0.2 s + UP cadence + hysteresis-crossing before the gait flips; backlog builds meanwhile. **Unregistered deviation**; premise refuted (headline 1) |
| DEV-3 | HIGH | Stop is three unwired mechanisms (UM-Ready overwrite / dead UP-velocity StopCompletely / landing); no `StopSequenceMotion` path; nothing zeroes body velocity or reconciles the queue | Direct cause of stop-slide: pose goes Ready while the body drains pre-stop waypoints at up to 2× maxSpeed |
| DEV-4 | MED (corrected) | Fix B drops the authored walk↔run link **pose** (hard visual cut + lost link root motion). ~~Velocity snap~~ — REFUTED: retail snaps velocity instantly too | Visual-only hard cut at each toggle; NOT a position-error source |
| DEV-5 | HIGH | Inbound movement types 8/9 (TurnToObject/TurnToHeading) dropped → misapplied as `SetCycle(Ready)` | Mid-locomotion turn command becomes a spurious full stop — error injected exactly during "compounding through running/turning" |
| DEV-6 | MED | Sequence-number staleness gate missing (movementSequence/serverControlSequence/isAutonomous parsed-then-discarded; retail gates on `update_times[8]`) | A reordered stale UM re-applies the old gait or un-stops a stop (extends register TS-26 to UM) |
| DEV-7 | MED | `pending_motions`/`pending_animations`/`MotionDone`/`remove_redundant_links` lifecycle absent | Enabler: missing `remove_redundant_links` is what made rapid toggles restart links forever, forcing Fix B; without MotionDone nothing clears per-transition bookkeeping |
| DEV-8 | MED | Remote DR velocity synthesized from hardcoded gait constants at SetCycle time vs retail's MotionData-accumulated CSequence velocity (+ baked per-frame anim deltas) | Any per-gait speed mismatch vs ACE's integration = constant-rate error that fills the interp backlog → discharges as slide/snap |
| DEV-9 | MED | Modifier layer missing: no `re_modify`, single-cycle selection, omega formula-seeded instead of combine/subtract | Turn contribution approximated independently of the motion table → heading-rate error while running+turning |
| DEV-10 | LOW | Bifurcated position-drive paths; grounded player remotes skip the per-tick collision sweep (retail sweeps every entity) | None for this symptom directly; parity + pass-through-geometry hazard |
### Key anchors per deviation
- **DEV-1** — retail: pseudo-C 305936 (`move_to_interpreted_state` 0x005289c0),
305713 (`apply_interpreted_movement` 0x00528600), 298636 (`GetObjectSequence`
0x00522860), 293301 (`copy_movement_from` 0x0051e750). acdream:
`GameWindow.cs:4590/4598` (bulk-copy), `:4769` (separate SetCycle), `:4517`
(comment-only funnel ref); D6.2 raw-input funnel is local-player-only
(`PlayerMovementController.cs:761-1014`). Nuance (verify correction): the
sidestep/turn axes DO route through `DoInterpretedMotion`/`StopInterpretedMotion`
(`GameWindow.cs:4794-4857`); the forward axis — the crux — is the bulk-copy.
- **DEV-2** — acdream: `GameWindow.cs:5095/5104/5110` (constants),
`:5112-5300` (both methods), `:5128-5134` (the unconfirmed-premise comment).
Retail: no equivalent exists (searched: InterpolationManager/PositionManager
ranges 352000-353400 — zero `CMotionInterp` writes; `MoveOrTeleport` velocity
param dead at 284304).
- **DEV-3** — retail: 298954 (`StopSequenceMotion` 0x00522fc0), 305635
(`StopInterpretedMotion` 0x00528470), 301194 (`clear_physics` 0x00524d50).
acdream: `GameWindow.cs:4364-4367` (UM-Ready), `:5711-5731` (dead UP-velocity
stop — ACE player UPs carry velocity=null per `:5700-5710`), `:5527-5553`
(landing = only `Interp.Clear` site); plus the acdream-invented 300 ms
stop-detection window (`GameWindow.cs:4862-4890` + TickAnimations) — also
retail-less, part of this cluster.
- **DEV-4** — retail: 298636 link branch; 298552 (`get_link`); 301777
(`append_animation`); velocity-overwrite refutation: 298437-298468
(`add_motion` 0x005224b0) + 300798-300814 (`set_velocity`/`set_omega`
overwrites), ACE `MotionTable.cs:144-168` + `Sequence.cs:127-130/221-230`.
acdream: `AnimationSequencer.cs:585-635` (Fix B), `:686-754` (velocity
synthesis — retail-equivalent in timing).
- **DEV-5** — retail: 300563/300707 (jump table cases 8/9 →
`MoveToManager::TurnToObject/TurnToHeading`). acdream:
`UpdateMotion.cs:136-238` (no case 8/9), `GameWindow.cs:4364-4367`
(null-command → Ready misfire). Confirmed live earlier: `mt=0x09` arrives,
acdream plays Ready (2026-06-26 audit D9).
- **DEV-6** — retail: 271370 (`SetObjectMovement` staleness gate), 357224
(`update_times[8]` compare). acdream: `UpdateMotion.cs:89-106`,
`UpdatePosition.cs:29-31/156` (parsed-then-discarded).
- **DEV-7** — retail: 305238 (`MotionDone` 0x00527ec0), 290854
(`MotionTableManager::add_to_queue` 0x0051bfe0), 290771
(`remove_redundant_links` 0x0051bf20), 290645 (`CheckForCompletedMotions`).
acdream: `AnimationSequencer.cs:401` (always-synchronous SetCycle),
`:1128-1129` (`AnimationDoneSentinel` exists, unconsumed by any motion
bookkeeping).
- **DEV-8** — retail: 298437-298492 (`add_motion`/`combine_motion`/
`subtract_motion`), 302402 (`CSequence::update` — anim-frame deltas are the
primary drive), 305160 (`get_state_velocity` constants 3.12/4.0 — the
*logical* values acdream synthesizes). acdream: `AnimationSequencer.cs:686-754`.
- **DEV-9** — retail: 298636 (`re_modify` at rebuild tail), 298954 (modifier
`subtract_motion` branch), 305713 (per-slot dispatch). acdream:
`GameWindow.cs:4648-4683` (single-cycle pick), `:4841-4847` (ObservedOmega
formula seed), `:10097-10106` (manual omega integration).
- **DEV-10** — retail: 280817/283611 (sweep for all entities). acdream:
`GameWindow.cs:9717-9722` (fork), `:9883-9890` (sweep skipped, player-remote
path), `:10156-10181` (sweep present, NPC path), `:5657` (NPC UP hard-snap).
## Recommended port campaign (for approval — no code written yet)
Ordered so each slice is independently landable, tests-first, minimal-surgery
(no sequencer rewrite; `SetCycle` internals stay). Every slice updates
`docs/architecture/retail-divergence-register.md` in the same commit.
- **S0 — the wire probe (no code).** Live capture, acdream as observer,
actor driven from retail: `ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1`,
structured protocol with the walk↔run Shift toggle as the centerpiece;
optionally WireMCP on loopback :9000 for the raw S→C bytes. Expected per the
oracles: a UM with ForwardCommand flipping Walk↔Run on every toggle
(`[UM_RAW]` is the arbiter — the 2026-05-06 test read `[FWD_WIRE]`, which
only fires on interpreted-command *change* after acdream's own resolution).
Outcome decides DEV-2's disposition. *(Needs the user driving the retail
client — this is the one stop-and-wait step.)*
- **S1 — DEV-6 staleness gate.** ~30 isolated lines: capture the two u16s into
`UpdateMotion.Parsed`, retail wraparound-newer compare per guid, drop stale.
Unit-testable against the 0x7fff wraparound rule. Safe first commit.
- **S2 — DEV-1 funnel (the centerpiece), absorbing DEV-3 + DEV-9 dispatch.**
Port `move_to_interpreted_state` + `apply_interpreted_movement` into
`MotionInterpreter`, with `DoInterpretedMotion` routing to the existing
`AnimationSequencer.SetCycle` as the `GetObjectSequence` backend.
`OnLiveMotionUpdated`'s remote SubState branch collapses to one
`move_to_interpreted_state(ims)` call; stop flows through
`StopInterpretedMotion` (no `Interp.Clear()` band-aid — the queue's final
waypoints ARE the true stop position); sidestep/turn become per-slot retail
dispatch. Parity test first: replay a captured UM stream through old + new
paths, assert identical SetCycle sequence + InterpretedState trace; then a
stop-slide acceptance case.
- **S3 — DEV-7 bookkeeping.** `MotionTableManager` companion (pending_animations,
`add_to_queue`, `remove_redundant_links`, `CheckForCompletedMotions`) wiring
the existing `AnimationDoneSentinel`; `pending_motions`/`MotionDone` in
`MotionInterpreter` second. Additive, decomp-semantics unit tests first.
- **S4 — DEV-4 corrected: retire Fix B.** Restore the link-pose enqueue for
locomotion↔locomotion now that S3's `remove_redundant_links` removes Fix B's
original justification (link-restart spam). Velocity stays instant
(retail-correct). Acceptance: user visual side-by-side.
- **S5 — DEV-2 disposition per S0.** If UMs arrive: delete the refinement layer
(dead weight + latency + feedback loop) and let the S2 funnel drive. If the
wire is genuinely silent: keep a minimal layer as an explicitly registered
ACE-adaptation row (a retail client on the same wire would show the stale
gait too — matching retail display means NOT inferring), and file the gap
against ACE. Either way DEV-2's machinery stops being an unregistered
deviation.
- **S6 — remainder.** DEV-5 (parse types 8/9 → heading target; cross-check field
order vs Chorizite + holtburger first), DEV-8 (evidence first: dump Humanoid
MotionTable Walk/Run `MotionData.Velocity`+Flags from the dat, diff synthesized
magnitudes vs ACE's integration), DEV-10 deferred (fold-in after S2; NPC
hard-snap replacement needs explicit user approval per the reverted-campaign
precedent).
## What we've ruled out
- "acdream lacks any transition machinery" — false; `SetCycle` resolves authored
links (`GetLink`) and has the retail same-substate fast path (SCFAST ≈
`change_cycle_speed`).
- "Retail paces the walk↔run *velocity* change through the link clip" — refuted
(headline 3); instant velocity snap is retail-correct.
- "Retail refines the remote's cycle from UP-derived velocity" (#39's premise)
— refuted; no such mechanism exists in the decomp (two independent dives +
ACE cross-check).
- The `InterpolationManager` chase itself — already a verbatim port (L.3);
constants re-verified this session (2× adjusted max speed, 0.05 desired
distance, 30%/5-frame stall, fail>3 blip, 20-node cap, 96-unit gate upstream).
## What this is NOT
This is NOT a "port the whole CSequence/CPartArray stack" campaign — the
sequencer's node/link/queue mechanics are close enough that the funnel (DEV-1),
bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically;
and it is NOT a dead-reckoning-tuning problem — the chase is already retail,
it's the inputs (cycle + velocity source + stop signal) that deviate.
## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture)
Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured
protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`).
1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles
arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007
fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's
default-difference packing baselines `forward_command` against **Ready**
(`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is
non-default and always packed, so ACE (`MovementData.cs:104-119`) always
re-emits it with the holdKey upgrade + run-skill speed.
2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE**
— they are genuine keys-released / heading-only MoveToState relays
(all-default raw state packs nothing; ACE `RawMotionState.Read` leaves
absent = Invalid; `MovementData` emits nothing). Retail semantics
(verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults
`forward_command=Ready`; `MovementManager::unpack_movement` case 0
UnPacks into a default-constructed state;
`CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT
`copy_movement_from` + `apply_current_movement`): an empty UM = a real
full stop. acdream already maps it to Ready — but the DEV-2 refinement
then re-promotes the cycle from UP pace after the 0.2 s grace, producing
a Ready↔Run thrash against legitimate stop signals. That thrash is the
observed flicker/rubber-band component.
3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs;
movementSeq advances +1 per UM; byte-level layout confirmed
(`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where
the parser reads them).
4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the
outer style u16 routes through `command_ids[]` and applies via
`DoMotion` ONLY when it differs from the current style; motionFlags
carries sticky-object (0x100 → `stick_to_object`) and standing_longjump
(0x200); the actions list is consumed under a 15-bit
`server_action_stamp` wraparound compare
(`move_to_interpreted_state` 0x005289c0, lines 305953-305989).
**Disposition: S5 = delete the player pace-inference layer** (executed in the
same session; NPC `PlanFromVelocity` path untouched — its unification is S6).
## Provenance
- Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port /
acdream code / prior-research claims — full reports preserved in
`2026-07-02-inbound-motion-maps/`) → synthesis → 2-lens adversarial verify
per deviation (25 agents, ~3.0 M tokens). The ACE-port mapper crashed on
structured output AFTER writing its report file; synthesis read all four
files. One verify lens (DEV-4 retail side) delivered the REFUTED verdict
incorporated above.
- Targeted agents: retail remote-anim adaptation dive (+2 children:
InterpolationManager decomp dump, ACE InterpolationManager cross-check).
- First-hand session reads: `OnLiveMotionUpdated` (GameWindow 4230-4930),
DR tick path (9700-9920), `AnimationSequencer.SetCycle` (340-790),
`ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement`
(5077-5300), `ServerControlledLocomotion`, `PositionManager`,
`InterpolationManager`, `UpdatePosition` parser, ACE `Player_Tick.cs` /
`Player_Networking.cs` / `GameActionMoveToState.cs`, decomp
`SendMovementEvent`/HoldRun sites (699274-700312), 2026-06-04 deep-dive
divergence list, ISSUES #39, divergence register scan.
- Corrections applied to the raw workflow output: DEV-1 anchor fix
(`MotionInterpreter.cs:399/412/558` reference different retail functions —
the only funnel comment-refs are `GameWindow.cs:4517` +
`PlayerMovementController.cs:593`); DEV-4 rewritten per its refutation.

View file

@ -0,0 +1,208 @@
# acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map
Repo: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do.
---
## 0. File inventory (all line numbers verified against current worktree)
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (333 lines) — inbound 0xF74C parser.
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (177 lines) — inbound 0xF748 parser.
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (108 lines) — **OUTBOUND ONLY** (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role.
- `src/AcDream.App/Rendering/GameWindow.cs` (13,759 lines) — `OnLiveMotionUpdated` (42304967), `OnLivePositionUpdated` (5334~5773), `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` (51125333), `TickAnimations` (9673~10267+), `RemoteMotion` class (401~592).
- `src/AcDream.Core/Physics/AnimationSequencer.cs` (1583 lines) — `SetCycle` (401788), `Advance` (862957), helpers.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` (1255 lines) — `DoInterpretedMotion`/`StopInterpretedMotion`/`StopCompletely`/`get_state_velocity`/`adjust_motion`/`apply_raw_movement`/`apply_current_movement`/`GetMaxSpeed`.
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes.
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines) — velocity→cycle classifier for non-player remotes (`PlanFromVelocity`) and MoveTo-start seed (`PlanMoveToStart`).
- `src/AcDream.Core/Physics/InterpolationManager.cs` (389 lines) — retail `CPhysicsObj` position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail).
- `src/AcDream.Core/Physics/PositionManager.cs` (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes.
**No `pending_motions` / `MotionDone` lifecycle exists anywhere in the codebase.** `grep -rn "pending_motion|MotionDone|PendingMotion" src/` returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding).
---
## Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object
1. **Wire parse.** `AcDream.Core.Net.Messages.UpdateMotion.TryParse` (`UpdateMotion.cs:70-253`) decodes opcode `0xF74C` into `Parsed(uint Guid, CreateObject.ServerMotionState MotionState)`. Extracts: `Guid`, `currentStyle` (Stance), and — when `movementType == 0` (InterpretedMotionState) — optional `ForwardCommand`/`ForwardSpeed`/`SideStepCommand`/`SideStepSpeed`/`TurnCommand`/`TurnSpeed` plus a `Commands` list (actions/emotes), gated by a 7-bit flags dword (`UpdateMotion.cs:145-226`). When `movementType is 6 or 7` (MoveToObject/MoveToPosition), it instead parses a `MoveToPathData` payload via `TryParseMoveToPayload` (`UpdateMotion.cs:255-331`): target guid (type 6 only), Origin cell+xyz, `MovementParameters` dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate.
- **Fields parsed then discarded / not used for pose**: `instanceSequence` (u16, `UpdateMotion.cs:85-87`, "tracked but not used for pose" — actually not even stored, just skipped), `movementSequence`+`serverControlSequence` (u16+u16 inside the 6-byte MovementData header, `UpdateMotion.cs:89-106`, skipped wholesale via `pos += 6`), `isAutonomous` (u8, same 6-byte skip — never separately read), `_motionFlags` (u8, read into a local but only used for the diagnostic dump at `UpdateMotion.cs:111,115-122`, otherwise discarded), `distanceToObject`/`failDistance`/`walkRunThreshold`/`desiredHeading` inside `TryParseMoveToPayload` (parsed at `UpdateMotion.cs:305-306,309,313,315` into locals, never placed on `MoveToPathData` — only `distanceToObject`(as `DistanceToObject`), `minDistance`, `failDistance`(discarded — not passed to the record), `walkRunThreshold` and `desiredHeading` ARE passed to `MoveToPathData` ctor at `UpdateMotion.cs:319-329` but `MoveToPathData` doesn't expose `FailDistance` in the earlier local list — check record fields separately if exact retention matters).
- Similarly, `UpdatePosition.TryParse` (`UpdatePosition.cs:77-175`) decodes 0xF748 into guid, `ServerPosition` (cellId+xyz+quat), optional `Velocity`, optional `PlacementId`, `IsGrounded` flag, and three of four trailing u16 sequence numbers (`instSeq`, `teleSeq`, `forceSeq` — the second one, "positionSequence", is explicitly skipped: `UpdatePosition.cs:156` comment "not tracked by movement"). None of `instSeq`/`teleSeq`/`forceSeq` are consulted anywhere downstream for freshness/ordering (comment at `UpdatePosition.cs:29-31`: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
2. **Session dispatch → GameWindow event.** (Not directly inspected this pass, but referenced via `_liveSession.MotionUpdated += OnLiveMotionUpdated;` at `GameWindow.cs:2633`.) The parsed `UpdateMotion.Parsed` becomes an `AcDream.Core.Net.WorldSession.EntityMotionUpdate` and fires the `MotionUpdated` event.
3. **`GameWindow.OnLiveMotionUpdated`** (`GameWindow.cs:4230`) is the sole consumer.
- Early-outs: `_dats is null` (4232), entity not found by `update.Guid` in `_entitiesByServerGuid` (4233), or no `AnimatedEntity` for it in `_animatedEntities` (4234).
- Stamps `rmStateForUm.LastUMTime` on the `_remoteDeadReckon[update.Guid]` entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / `UmGraceSeconds`).
- Reads `stance = update.MotionState.Stance` and `command = update.MotionState.ForwardCommand` (nullable) (4259-4260).
- **Sequencer path** (the only path exercised in practice — `ae.Sequencer is not null` at 4327) reconstructs a full 32-bit `fullMotion` command from the wire's 16-bit `ForwardCommand` via `MotionCommandResolver.ReconstructFullCommand` (4372-4378), OR seeds a MoveTo-derived motion via `ServerControlledLocomotion.PlanMoveToStart` when `IsServerControlledMoveTo` and no forward command (4356-4363), OR falls back to `0x41000003` (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal").
- **Branches on `update.Guid == _playerServerGuid`**: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (`else` branch at 4493) path.
- For a genuine remote: bulk-copies `fullMotion`/`speedMod` into `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` UNCONDITIONALLY (4572-4598) — this is the direct feed into `MotionInterpreter.get_state_velocity()` (Q5). Then classifies via `AnimationCommandRouter.Classify(fullMotion)` into `forwardIsOverlay` (Action/Modifier/ChatEmote — routed via `RouteFullCommand`, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 `else` block — this is Q2/Q3/Q4's territory).
---
## Q2 — TRANSITION: walk↔run while already moving (no stop)
Frame-by-frame, for the SubState (non-overlay) branch at `GameWindow.cs:4637-4859`:
1. **Cycle selection** (4648-4683): `animCycle = fullMotion`, `animSpeed = speedMod` by default. If the forward command is NOT run/walk/walkback (`fwdIsRunWalk` false), fall back to sidestep or turn cycle. **For a plain walk↔run toggle, `fullMotion` IS run/walk, so this stays the forward cycle** — no fallback triggered.
2. **Airborne guard** (4696): if `remoteIsAirborne`, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle.
3. **Cycle-existence fallback chain** (4728-4757): if the MotionTable lacks `(fullStyle, cycleToPlay)`, falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist.
4. **`ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)`** is called (4769) — this is the entry into `AnimationSequencer.SetCycle` (`AnimationSequencer.cs:401`).
### Inside `SetCycle` (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:
- `adjust_motion` remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (`adjustedMotion = motion`).
- **Fast-path check** (440-468): fires only if `CurrentStyle == style && CurrentMotion == motion` (same exact motion command) AND sign of speedMod matches. **On an actual Walk→Run toggle, `CurrentMotion` (0x45000005 WalkForward) != `motion` (0x44000007 RunForward), so the fast path does NOT fire** — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.)
- **Full-rebuild path** (470-788):
- `GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed)` looks up a transition-link MotionData from the MotionTable's `Links` dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: `Links[(style<<16)|WalkForward][RunForward]`.
- `ClearCyclicTail()` (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone.
- `ClearPhysics()` (529) — zeroes `CurrentVelocity`/`CurrentOmega` before rebuild.
- Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via `EnqueueMotionData` (547-554).
- **Direct cyclic→cyclic transition special-case** (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (`IsLocomotionCycleLowByte`, checks against 0x05/0x06/0x07/0x0F/0x10 — `AnimationSequencer.cs:1509-1513`), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces `_currNode = _firstCyclic` directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's `add_to_queue` sequence for a Walk→Run direct transition never fires `truncate_animation_list` and just appends the new cycle directly — i.e., **acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame** (`_framePosition = _firstCyclic.Value.GetStartFramePosition()`, 634).
- If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (`_currNode = firstNew` at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run.
- `CurrentStyle`/`CurrentMotion`/`CurrentSpeedMod` updated (682-684).
- **Velocity synthesis** (686-754): because retail's Humanoid MotionData ships `Flags=0x00` (no `HasVelocity`) for locomotion cycles, `CurrentVelocity` is unconditionally re-synthesized here from hardcoded constants (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` — 793-795) times `adjustedSpeed`, based on the NEW motion's low byte. **This happens regardless of whether the link or the cycle is what's currently playing** — i.e. `CurrentVelocity` snaps to the new cycle's steady-state speed the instant `SetCycle` returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing.
- Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run).
### Frame-by-frame summary for Walk→Run toggle while moving:
- **Frame N (UM arrives)**: `OnLiveMotionUpdated` fires → `SetCycle(style, RunForward, speedMod)` called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, `_currNode` snapped directly to Run cycle's start frame, `CurrentVelocity` synthesized to `RunAnimSpeed × speedMod` — ALL of this happens on the SAME UM-processing call, before the next `Advance()`/tick.
- **Frame N (same tick, later in loop)**: `TickAnimations` (`GameWindow.cs:9673`) runs later that frame (or next frame depending on event/tick ordering) and calls `Advance(dt)` on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose.
- **Is there blending?** NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case.
- **Is the new motion appended/queued, or does it replace?** It REPLACES: `ClearCyclicTail()` always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains.
- **Speed**: `CurrentVelocity`/animation framerate both change immediately and atomically inside `SetCycle` — no interpolation of speed either (the fast-path's `MultiplyCyclicFramerate` smooth-rescale only applies when `CurrentMotion` is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update).
---
## Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle)
**NONE.** There is no `pending_motions` queue and no `MotionDone` callback/event anywhere in the codebase (confirmed via `grep -rn "pending_motion|MotionDone|PendingMotion" src/` — zero hits).
What DOES exist that's adjacent:
- `AnimationSequencer._queue` (a `LinkedList<AnimNode>`, `AnimationSequencer.cs:297`) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every `SetCycle` call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — `SetCycle` always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives.
- `AnimationSequencer.ConsumePendingHooks()` (964-972) exists and drains `_pendingHooks`, a `List<AnimationHook>` accumulated during `Advance()` when integer frame boundaries are crossed (`Advance`, 862-957, calls `ExecuteHooks` at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal.
- `AnimationDoneSentinel` (`AnimationSequencer.cs:1128-1129`, a static `AnimationHookDir.Both` hook) is added to `_pendingHooks` when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside `Advance`). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME `_pendingHooks` list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's `pending_motions`/`MotionDone` state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent).
- **Consequence for callers**: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls `SetCycle`/`DoInterpretedMotion` directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in `OnLiveMotionUpdated` (4626 vs 4637) and (b) `SetCycle`'s own state (`CurrentMotion`/`CurrentStyle` comparison) — there's no notion of "motion command A is still pending completion, defer motion command B."
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over?
Governed entirely by `AnimationSequencer.SetCycle` (see Q2 for the walk→run specific path) and `AdvanceToNextAnimation` (`AnimationSequencer.cs:1344-1364`):
- **General case (non-locomotion-direct, e.g. Ready→WalkForward)**: `_currNode` is set to the FIRST NEWLY-ENQUEUED node (`firstNew`, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS `GetStartFramePosition()` (583+639: `_framePosition = _currNode.Value.GetStartFramePosition()`), which for positive framerate is `StartFrame` (frame 0 of the link) — **frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame.** When the link naturally exhausts during subsequent `Advance()` calls, `AdvanceToNextAnimation()` (1344) walks to `_currNode.Next`, or if null, wraps to `_firstCyclic` — again restarting `_framePosition` at `GetStartFramePosition()` of that new node (1362-1363).
- **Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, `AnimationSequencer.cs:619-635`)**: `_currNode` is forced DIRECTLY onto `_firstCyclic` (the new cycle's looping node), with `_framePosition = _firstCyclic.Value.GetStartFramePosition()` (634) — i.e. **frame index is reset to 0 (or `EndFrame` for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position.** There is no "keep the current phase, just swap animation data" — every `SetCycle` transition, link or no link, hard-resets `_framePosition` to a start boundary of whichever node becomes current.
- **No "link animation between the two loops" mechanism for locomotion-direct swaps** — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618).
- Net: cycle swap between two looping cycles is an **instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation.** This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active `AnimNode` playback engine, not a blend-tree.
---
## Q5 — POSITION DRIVE between inbound position packets
Two structurally DIFFERENT paths exist, selected in `TickAnimations` (`GameWindow.cs:9717-9722`) by:
```csharp
if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
if (IsPlayerGuid(serverGuid) && !rm.Airborne) // ← Path A: grounded PLAYER remotes
else // ← Path B: NPCs + airborne player remotes (legacy)
}
```
### Path A — grounded player remotes (`GameWindow.cs:9722-9920`)
Uses `PositionManager.ComputeOffset` (`PositionManager.cs:51-107`), which is explicitly a **REPLACE, not additive** combiner:
1. `InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)` (called at `PositionManager.cs:84`) is tried FIRST.
- If the queue has waypoints AND the body is farther than `DesiredDistance` (0.05 m, `InterpolationManager.cs:79`) from the head waypoint, it returns a non-zero "catch-up" delta: `direction × min(catchUpSpeed × dt, dist)`, where `catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)` or a `MaxInterpolatedVelocity` (7.5 m/s) fallback (`InterpolationManager.cs:262-362`). **This REPLACES animation root motion entirely for that frame** (`PositionManager.cs:84-86`: `if (correction.LengthSquared() > 0f) return correction;`).
- If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via `NodeCompleted`), `AdjustOffset` returns `Vector3.Zero`, and `PositionManager.ComputeOffset` falls through to animation root motion: `seqVel × dt`, rotated by body orientation, optionally slope-projected (`PositionManager.cs:88-106`).
2. `seqVel` is `ae.Sequencer.CurrentVelocity` (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT `MotionInterpreter.get_state_velocity()`.
3. `omegaToApply` is `rm.ObservedOmega` (seeded formulaically from wire TurnCommand+TurnSpeed in `OnLiveMotionUpdated`, `GameWindow.cs:4841-4847`) preferred over `seqOmega` (`GameWindow.cs:9842-9845`).
4. `rm.Body.calc_acceleration()` + `rm.Body.UpdatePhysicsInternal(dt)` still run (9878, 9881) for gravity/vertical integration, but `ResolveWithTransition` (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").
5. **Dominance**: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's `CurrentVelocity` (animation root motion) only drives position when the queue is idle/caught-up.
### Path B — NPCs and airborne player remotes (legacy path, `GameWindow.cs:9933-10267+`)
This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923):
1. `rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)` (10074, the general branch) — calls `MotionInterpreter.get_state_velocity()` (`MotionInterpreter.cs:639-688`), which reads `InterpretedState.ForwardCommand`/`ForwardSpeed` (bulk-copied from the wire in `OnLiveMotionUpdated`, Q1/Q2) and returns `WalkAnimSpeed/RunAnimSpeed × ForwardSpeed` UNLESS the sequencer's `CurrentVelocity.Y` is non-zero (`GetCycleVelocity` accessor, wired — see below), in which case it prefers that (`MotionInterpreter.cs:653-668`, "Option B — MotionData-sourced forward velocity"). Result is written directly to `PhysicsBody.set_local_velocity` via `apply_current_movement` (`MotionInterpreter.cs:905-909`, gated on `PhysicsObj.OnWalkable`).
2. Alternative sub-branches at this same call site (`GameWindow.cs:9963-10076`) select DIFFERENT velocity sources depending on state:
- If `rm.HasServerVelocity` and NOT a player guid (9968): uses `rm.ServerVelocity` (synthesized from consecutive UP position deltas, or wire `Velocity` field when present) directly as `rm.Body.Velocity` — BYPASSING `apply_current_movement`/`get_state_velocity` entirely — UNLESS the velocity is stale (`velocityAge > ServerControlledVelocityStaleSeconds = 0.60s`, 926), in which case it's zeroed and `ApplyServerControlledVelocityCycle` is invoked to reset the cycle to Ready.
- If `rm.ServerMoveToActive && rm.HasMoveToDestination` (9987): uses `RemoteMoveToDriver.Drive` to steer orientation, then `apply_current_movement` for velocity magnitude (10042), clamped via `RemoteMoveToDriver.ClampApproachVelocity` (10052-10059) to avoid overshoot.
- Else (general fallback, 10074): `apply_current_movement` as described in step 1.
3. Manual per-tick rotation integration (`GameWindow.cs:10097-10106`) using `rm.ObservedOmega`.
4. `rm.Body.UpdatePhysicsInternal(dt)` (10128) — Euler integration.
5. **Collision sweep IS run for Path B** (`_physicsEngine.ResolveWithTransition`, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights).
### Which dominates?
- Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized `CurrentVelocity` (animation root motion) is the fallback when the queue is idle.
- Path B (NPCs / airborne players): `MotionInterpreter.get_state_velocity()` (fed by `InterpretedState.ForwardCommand/ForwardSpeed` set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's `CurrentVelocity.Y` is non-zero, `get_state_velocity` PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (`MotionInterpreter.cs:646-668` "Option B"). Wire-synthesized `ServerVelocity` from position deltas takes priority over both when present and fresh.
- **Neither `apply_raw_movement` nor the raw `adjust_motion`(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path** (confirmed via `grep -rn "apply_raw_movement|get_state_velocity()"` across `src/` outside `MotionInterpreter.cs` — only `src/AcDream.App/Input/PlayerMovementController.cs` calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 `apply_raw_movement`/`adjust_motion` port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get `InterpretedState.ForwardCommand`/`ForwardSpeed` bulk-copied directly (mirroring retail's `copy_movement_from`, per the comment at `GameWindow.cs:4536-4544,4552-4558`), bypassing `adjust_motion`'s left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments.
---
## Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error)
Handled in `OnLivePositionUpdated` (`GameWindow.cs:5334-~5773`). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne:
### Player remotes (`IsPlayerGuid(update.Guid)` branch, `GameWindow.cs:5460-5635`)
Ports retail's `CPhysicsObj::MoveOrTeleport` (0x00516330):
1. **Airborne no-op** (5512-5521): if `!update.IsGrounded`, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap).
2. **Landing transition** (5527-5553): first grounded UP after airborne clears `Airborne`, zeroes velocity, hard-snaps `rmState.Body.Position = worldPos` directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever `InterpretedState.ForwardCommand` implies.
3. **Grounded routing** (5555-5581): distance check against the LOCAL player's position (`MaxPhysicsDistance = 96f`, 5556):
- `dist > 96`: hard `SetPositionSimple`-style snap — `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` (no smoothing at all).
- `dist <= 96`: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position)` — feeds `InterpolationManager.Enqueue` (`InterpolationManager.cs:170-231`), which itself has three sub-branches:
- **Far branch** (dist from last-tail/body-position to new target > `AutonomyBlipDistance = 100m`): enqueues AND pre-arms an immediate blip (`_failCount = StallFailCountThreshold + 1 = 4`) so the very next `AdjustOffset` call snaps straight to the tail.
- **Already-close branch** (body-to-target ≤ `DesiredDistance = 0.05m`): wipes the queue entirely, no enqueue (`Clear()`).
- **Near/not-close branch**: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends.
- Every-tick catch-up happens later in `TickAnimations` Path A via `PositionManager.ComputeOffset``InterpolationManager.AdjustOffset` (Q5). There is a secondary "stall" detector inside `AdjustOffset` (`InterpolationManager.cs:292-337`): every 5 frames it checks cumulative progress against `MinDistanceToReachPosition = 0.20m` and a secondary ratio check (`cumulative/progressQuantum/dt >= 0.30`); repeated stall failures (`_failCount > StallFailCountThreshold = 3`) trigger a hard SNAP to the tail waypoint (`InterpolationManager.cs:345-350`) and clear the queue.
4. **Velocity-fallback cycle refinement** (5582-5625): synthesizes velocity from consecutive server positions (`(worldPos - PrevServerPos) / dtPos`) and feeds `ApplyServerControlledVelocityCycle``ApplyPlayerLocomotionRefinement` (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle.
5. `entity.SetPosition(rmState.Body.Position)` at the end (5633) — the render entity always mirrors the body, never `worldPos` directly, for player remotes within the near-branch (queue chase still in progress).
### Non-player remotes (`GameWindow.cs:5637-5767`)
NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction:
1. `serverVelocity` sourced from wire `update.Velocity` if present, else synthesized from position delta if `rmState.LastServerPosTime > 0` (5638-5646).
2. `rmState.Body.Position = worldPos`**UNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check** (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too."
3. `rmState.Body.Orientation = rot` — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded `ObservedOmega`, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn).
4. `rmState.Body.Velocity = svel` if wire `Velocity` present (5711-5713); **`svel.LengthSquared() < 0.04f` is treated as an explicit stop signal** — triggers `rmState.Motion.StopCompletely()` (Q7) PLUS a direct `SetCycle` to Ready (5714-5731). This is the ONLY place `StopCompletely()` is called anywhere in the inbound path.
5. Between UPs (Path B in `TickAnimations`), NPCs get a client-side `ResolveWithTransition` collision sweep — see Q5.
### Named thresholds used for correction
| Constant | Value | File:line | Purpose |
|---|---|---|---|
| `SnapHardSnapThreshold` | 20.0 m | `GameWindow.cs:614` | (declared, comment references retail `GetAutonomyBlipDistance`; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP) |
| `MaxPhysicsDistance` | 96 m | `GameWindow.cs:5556` | Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue) |
| `InterpolationManager.AutonomyBlipDistance` | 100 m | `InterpolationManager.cs:105` | Far-branch pre-arm blip in `Enqueue` |
| `InterpolationManager.DesiredDistance` | 0.05 m | `InterpolationManager.cs:79` | "Reached"/"already close" radius |
| `InterpolationManager.MinDistanceToReachPosition` | 0.20 m | `InterpolationManager.cs:73` | 5-frame stall-check primary threshold |
| `InterpolationManager.StallFailCountThreshold` | 3 (fires on >3, i.e. 4th fail) | `InterpolationManager.cs:98` | Snap-to-tail trigger |
| `ServerControlledVelocityStaleSeconds` | 0.60 s | `GameWindow.cs:926` | NPC server-velocity staleness → zero + Ready |
| `RemoteMoveToDriver.StaleDestinationSeconds` | 1.5 s | `RemoteMoveToDriver.cs:136` | MoveTo destination staleness → stand down |
---
## Q7 — STOP (motion → Ready/stand)
There are **THREE distinct, independently-triggered stop paths** in the current code, none of which are unified:
### 1. UM-driven stop (`OnLiveMotionUpdated`, the "retail stop signal")
When the wire's `ForwardCommand` flag is absent or explicitly 0 AND it's not a MoveTo packet (`GameWindow.cs:4347,4364-4367`): `fullMotion = 0x41000003u` (Ready) is used directly as the SetCycle target. This flows through the normal `SetCycle` full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (`IsLocomotionCycleLowByte` only covers 0x05/0x06/0x07/0x0F/0x10, `AnimationSequencer.cs:1509-1513`), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` are ALSO bulk-copied to `fullMotion`/`speedMod` = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so `MotionInterpreter.get_state_velocity()` will subsequently return `Vector3.Zero` (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. **No explicit `StopCompletely()`/`StopInterpretedMotion()` call happens in this path** — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM.
### 2. UP-driven stop for non-player remotes (near-zero wire velocity)
`GameWindow.cs:5711-5731`: only fires when the wire's `UpdatePosition.Velocity` field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls `rmState.Motion.StopCompletely()` (`MotionInterpreter.cs:562-587`) which resets BOTH `RawState` and `InterpretedState` to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls `PhysicsObj.set_velocity(Vector3.Zero)` directly this IS an explicit velocity zero. Additionally forces `ae.Sequencer.SetCycle(curStyle, readyCmd)` (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity.
### 3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split)
For player remotes, `OnLiveMotionUpdated`'s player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. **Position, however, is decoupled**: even after a Ready UM zeroes `InterpretedState`/switches the sequencer to Ready, the `InterpolationManager` queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since `PositionManager.ComputeOffset` prefers the queue's catch-up delta over `seqVel` whenever the queue is non-empty (`PositionManager.cs:84-86`), **the body can continue sliding toward the last few queued waypoints via `InterpolationManager.AdjustOffset`'s catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle** — because the queue-driven correction is independent of `CurrentVelocity`. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear `rmState.Interp`'s queue; the queue only self-empties via normal `NodeCompleted` head-pop as the body reaches each remaining waypoint (`InterpolationManager.cs:278-282`), or by explicit `Clear()` calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) `AdjustOffset`'s stall-blip clear, (d) the landing-transition block (5534, airborne case only). **There is no explicit `rm.Interp.Clear()` call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2)** — confirmed by reading `GameWindow.cs:4230-4967` (OnLiveMotionUpdated, no `Interp.Clear` call) and `GameWindow.cs:5711-5731` (the UP near-zero-velocity StopCompletely block, no `Interp.Clear` call there either — only the airborne-landing block at 5534 calls `rmState.Interp.Clear()`).
### Residual-sliding mechanisms explicitly present elsewhere (not stop-specific)
- `SnapResidualDecayRate = 8.0f` (1/sec, `GameWindow.cs:597`) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (`OnLivePositionUpdated`/`OnLiveMotionUpdated`/`TickAnimations` Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation.
### Summary answers
- (a) **Where remote DR velocity comes from**: for grounded PLAYER remotes, `AnimationSequencer.CurrentVelocity` (synthesized constants × speedMod, set inside `SetCycle`, NOT from `MotionInterpreter.get_state_velocity()`), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from `InterpolationManager.AdjustOffset`'s catch-up formula (`maxSpeedFromMinterp × 2.0`, itself sourced from `MotionInterpreter.GetMaxSpeed()` = `RunAnimSpeed(4.0) × runRate`). For NPCs (and airborne players), DR velocity comes from `MotionInterpreter.get_state_velocity()` (which itself prefers the sequencer's `CurrentVelocity.Y` when non-zero, else falls back to `WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed`), OR directly from `rm.ServerVelocity` (wire `Velocity` field or position-delta synthesis) when `HasServerVelocity` is true and fresh.
- (b) **When DR velocity changes after a new UM**: SAME FRAME / same synchronous call — `OnLiveMotionUpdated` bulk-copies `InterpretedState.ForwardCommand/ForwardSpeed` immediately (`GameWindow.cs:4590,4598`) and calls `SetCycle` immediately (4769), which synthesizes `CurrentVelocity` immediately inside the same call (`AnimationSequencer.cs:722-754`). There is no queueing/deferral — the very next `TickAnimations`/`Advance()` call (next frame) will observe the new velocity.
- (c) **Can animation phase and DR velocity disagree, for how long**: YES, structurally, in at least two ways: (1) For player remotes, `PositionManager` can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to `GetMaxSpeed()×2.0` or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The `SetCycle` cyclic-direct hard-cut (Q2/Q4) resets `_framePosition` to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap).
- (d) **How stop zeroes velocity, what can leave residual sliding**: `StopCompletely()` zeroes `PhysicsObj.set_velocity(Vector3.Zero)` directly on the body AND resets `InterpretedState`, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized `CurrentVelocity` (via `SetCycle`'s Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — `CurrentVelocity` simply stays whatever it was set to by `ClearPhysics()` at the top of `SetCycle`, i.e. `Vector3.Zero`, UNLESS a link with `HasVelocity` overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has `HasVelocity` set, `CurrentVelocity` would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, **NEITHER of the two stop paths clears `rmState.Interp`'s waypoint queue** — so if the queue still holds unreached waypoints from before the stop signal, `PositionManager.ComputeOffset`/`InterpolationManager.AdjustOffset` will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / `_failCount` exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case.
---
## Cross-cutting notes for synthesis
- **Two entirely separate motion-drive architectures coexist**: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older `MotionInterpreter.apply_current_movement`/`get_state_velocity` "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics.
- **`MotionInterpreter`'s D6.2 `adjust_motion`/`apply_raw_movement` port is entirely unused by the inbound remote path** — remotes get server-pre-normalized commands bulk-copied directly into `InterpretedState`, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (`PlayerMovementController.cs`).
- **No unified "stop" concept**: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case.
- **`SetCycle`'s cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism**, justified in-code by a cdb trace of retail's `add_to_queue` behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness.

View file

@ -0,0 +1,610 @@
# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics`
Key files (all confirmed to exist):
- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`)
- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`)
- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic)
- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation
- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager`
- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending)
- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal`
- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`)
- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick
- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs`
IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`).
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
---
## Q1 — INBOUND ENTRY: wire message → motion interpreter
### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp
1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20).
2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer.
3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).
4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`:
- Guards on `FastTick` (line 178).
- Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum).
- Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`:
- `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply)
- `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls)
5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path:
```csharp
var minterp = PhysicsObj.get_minterp();
minterp.RawState.SetState(moveToState.RawMotionState);
if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; }
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
minterp.apply_raw_movement(true, allowJump);
```
This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.**
6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site)
`PhysicsObj.cs:340-346`:
```csharp
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
LastMoveWasAutonomous = true;
if (MovementManager == null) return WeenieError.NoAnimationTable;
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
return MovementManager.PerformMovement(mvs);
}
```
`MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`).
`MotionInterp.DoMotion` (line 112):
- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119).
- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).
- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`.
- Action-count gating (line 147-151): `GetNumActions() >= 6``TooManyActions`.
- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below).
- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState).
### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
`Animation/MotionInterp.cs:51-110`:
1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne.
2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate).
3. Otherwise (line 64-90):
- `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67).
- `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion``GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4).
- On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3).
- If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state.
4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100).
5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
`contact_allows_move` (`MotionInterp.cs:584-602`):
```
Dead / Falling / TurnRight..TurnLeft range → always true
non-creature WeenieObj → always true
!PhysicsState.Gravity → true
!TransientState.Contact → false // airborne, no ground contact
TransientState.OnWalkable → true // standing on walkable floor
else → false
```
---
## Q2 — TRANSITION: walk↔run while already moving (no full stop)
### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller)
`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`):
```csharp
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
...
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
apply_interpreted_movement(cancelMoveTo, allowJump);
```
`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**:
```csharp
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
switch (motion)
{
case RunForward: return; // already run, no-op
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
}
if (motion == SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
}
```
`apply_run_to_command` (`MotionInterp.cs:525-562`):
```csharp
case WalkForward:
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id RunForward substate id
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
break;
case TurnRight: speed *= RunTurnFactor; break; // 1.5
case SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
speed = Math.Sign(speed) * MaxSidestepAnimRate;
break;
```
**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap.
### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand
`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`):
```csharp
if (InterpretedState.ForwardCommand == RunForward)
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
...sidestep, turn similarly...
```
### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps
`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt:
```csharp
if ((motion & CommandMask.SubState) != 0)
{
var motionID = motion & 0xFFFFFF;
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
...
if (is_allowed(motion, motionData, currState))
{
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
{
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
// since WalkForward != RunForward numerically)
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
subtract_motion(sequence, motionData, currState.SubstateMod);
combine_motion(sequence, motionData, speedMod);
currState.SubstateMod = speedMod;
return true;
}
// GENERAL PATH — actually taken for Walk->Run:
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
{
// fallback: go through the style's default substate as an intermediate hop
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
}
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
if (motionData_ != null)
{
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
add_motion(sequence, motionData_, speedMod); // append default->run link anim
}
else
{
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
}
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
currState.SubstateMod = speedMod;
currState.Substate = motion; // RunForward is now the tracked substate
re_modify(sequence, currState); // re-apply any active modifiers on top
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
return true;
}
}
```
**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation**`get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
---
## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone`
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
### Layer 1: `MotionInterp.PendingMotions` (`List<MotionNode>`, `MotionInterp.cs:24`)
- **Type**: `LinkedList<MotionNode>`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError).
- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`):
```csharp
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
PhysicsObj.IsAnimating = true;
```
Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495).
- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`):
```csharp
var motionData = PendingMotions.First;
if (motionData != null)
{
var pendingMotion = motionData.Value;
if ((pendingMotion.Motion & CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy)
if (motionData != null)
{
PendingMotions.Remove(motionData);
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
}
}
```
This is called ONLY from `PhysicsObj.MotionDone(motion, success)``MovementManager.MotionDone(motion, success)``MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id).
- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`.
- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf.
- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`, `Managers/MotionTableManager.cs:13`)
- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done.
- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`.
- Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
- **Popped/completed by TWO different drivers**:
1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`).
- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires:
```csharp
if (HookObj != null)
{
var node = AnimList.First;
if (!node.Equals(FirstCyclic))
HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
}
```
i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side.
### Summary of the 3-tier queue relationship
```
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
↑ pushed onto tail by add_motion() during GetObjectSequence
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
```
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.**
### `Sequence.append_animation` (`Sequence.cs:203-216`)
```csharp
public void append_animation(AnimData animData)
{
var node = new AnimSequenceNode(animData);
if (!node.has_anim()) return;
AnimList.AddLast(node);
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
if (CurrAnim == null)
{
CurrAnim = AnimList.First;
FrameNumber = CurrAnim.Value.get_starting_frame();
}
}
```
Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`.
### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended
```csharp
public void remove_cyclic_anims()
{
var node = FirstCyclic;
while (node != null)
{
if (CurrAnim.Equals(node))
{
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
}
```
This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch).
### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk
Forward-playback branch (`timeElapsed >= 0.0f`):
```csharp
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
currAnim = animNode.Value;
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame never carries phase
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
```
So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code.
### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes
```csharp
public void apricot()
{
var node = AnimList.First;
while (!node.Equals(CurrAnim))
{
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
}
```
Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
---
## Q5 — POSITION DRIVE between inbound packets: what advances position?
**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`):
```csharp
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
{
var offsetFrame = new AFrame();
if (!State.HasFlag(PhysicsState.Hidden))
{
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
else
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
}
if (PositionManager != null)
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
...
}
```
**Step 1 — `PartArray.Update``Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`):
```csharp
public void Update(float quantum, ref AFrame offsetFrame)
{
if (AnimList.First != null)
{
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
apricot();
}
else if (offsetFrame != null)
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
}
```
Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick:
```csharp
if (currAnim.Anim.PosFrames != null)
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
```
So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied.
**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable`**while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.**
**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
---
## Q6 — CORRECTION: how do inbound position updates fix accumulated error?
Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`):
```csharp
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
{
... staleness/sequence-number check against UpdateTimes[4] ...
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
{
teleport_hook(true);
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
return true;
}
else
{
if (!contact) return false;
if (PlayerDistance < 96.0f)
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
else
{
PositionManager?.StopInterpolating();
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
}
}
return true;
}
```
`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`.
### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`)
```csharp
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
var dist = dest.Distance(position);
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
{
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
{
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
}
else
{
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
StopInterpolating(); // close enough already — snap heading only, clear queue
}
}
else
{
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
enqueue new PositionType node; NodeFailCounter = 4;
}
```
### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick
```csharp
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
var first = queue.First.Value;
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
var dist = PhysicsObj.Position.Distance(first.Position);
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
var delta = OriginalDistance - dist;
ProgressQuantum += quantum; FrameCounter++;
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
{
// "making good enough progress" — keep smoothing
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
var offset = first.Position.Subtract(PhysicsObj.Position);
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
var distance = offset.Origin.Length();
if (distance <= 0.05f) NodeCompleted(true);
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
if (KeepHeading) offset.set_heading(0.0f);
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal added on top of animation displacement
return;
}
NodeFailCounter++;
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
```
Constants (verbatim, `InterpolationManager.cs:18-19`):
```
LargeDistance = 999999.0f
MaxInterpolatedVelocity = 7.5f
UseAdjustedSpeed = true (static, defaults on)
```
Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon.
Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
---
## Q7 — STOP: motion → Ready/stand
### Two distinct stop mechanisms in `MotionInterp`
**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
```csharp
if (contact_allows_move(motion))
{
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
else
{
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
}
}
}
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`):
```csharp
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
add_to_queue((uint)MotionCommand.Ready, counter, seq);
```
`MotionTable.StopObjectMotion``StopSequenceMotion` (`MotionTable.cs:315-356`):
```csharp
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
{
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
return true;
}
if ((motion & CommandMask.Modifier) == 0) return false;
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
```
**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose.
**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle:
```csharp
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`):
```csharp
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
while (currState.Modifiers.First != null)
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
// then stop the current Substate (Walk/Run/etc.) itself
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
```
### Velocity zeroing
`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
### Residual-slide prevention
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp:
```csharp
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
```
`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
### `RemoveLinkAnimations` on cell-exit
Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996``PartArray.HandleEnterWorld``MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3).

View file

@ -0,0 +1,92 @@
# Claim map: inbound motion/animation research vs current code
Date: 2026-07-02. Repo (worktree): `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
(branch `claude/vigorous-joliot-f0c3ad`, HEAD `b60b9b4a`).
**Path note:** `docs/research/2026-06-04-animation-sequencer-deep-dive.md` does NOT
exist in this worktree or in git history at all (it is an *untracked* file only
in the main checkout `C:/Users/erikn/source/repos/acdream/docs/research/...`).
It was never committed. Read directly from that absolute path for this audit.
The other two docs (`2026-06-26-movement-animation-retail-parity-audit.md`,
`2026-07-01-d6-motion-interp-pseudocode.md`) exist and are committed in this
worktree.
---
## Doc 1: 2026-06-04-animation-sequencer-deep-dive.md (8 ranked divergences)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| 1 | Missing `pending_motions`/`MotionDone` chain (MotionInterp, HIGH). `add_to_queue` + `CheckForCompletedMotions` unimplemented. Affects multi-step action sequences, combo attacks, `UpdateMotion Commands[]` action chains. (doc anchor: "Divergences ranked" #1, L1242-1247) | Grepped `MotionInterpreter.cs`/`AnimationSequencer.cs` for `pending_motions`/`MotionDone`/`CheckForCompletedMotions`/`add_to_queue` — only comment references (`FUN_00510900 — CheckForCompletedMotions (animation flush, not simulated here)`, `FUN_00528790(…) — add_to_queue`). No queue data structure, no `MotionDone` event. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:399,412,558` (comments only, no impl) |
| 2 | `contact_allows_move` level mismatch (MotionInterp, HIGH). acdream blocks Fallen/Dead/Crouch-range inside `contact_allows_move` itself; retail does this at `DoMotion` level with distinct error codes. (doc anchor: "Divergences ranked" #2, L1249-1253) | Read `contact_allows_move` body: still explicitly checks `fwd == MotionCommand.Fallen \|\| fwd == MotionCommand.Dead` and the crouch/sit/sleep range, returning `false` (blocked) directly from this function — exactly the divergence described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (see lines 1092-1099 for the Fallen/Dead/Crouch block) |
| 3 | `re_modify` not implemented after cycle rebuild (CSequence, MEDIUM). Stacked Modifier motions (combat stance overlays) lose velocity/omega contribution on cycle switch. (doc anchor: "Divergences ranked" #3, L1255-1260) | Grepped `AnimationSequencer.cs` for `re_modify`/`ReModify`/`ModifierList`/`_activeModifiers` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/AnimationSequencer.cs` (no match found for the term at all) |
| 4 | `TransparentHook` fires as instant snap vs smooth lerp (anim-hooks, MEDIUM). No `FPHook` time-interpolation equivalent. (doc anchor: "Divergences ranked" #4, L1262-1266) | Not directly re-verified this session (out of scope for walk/run + stop-slide focus — this is a fade/translucency concern, not motion/position). | **COULD-NOT-VERIFY** (not checked; low relevance to task's (a)/(b)/(c) scope beyond "(c) inbound animation machinery generally") | n/a |
| 5 | `direction_` default `0xFFFFFFFE` vs `AnimationHookDir.Both` (anim-hooks, LOW). Dat-baked hooks with missing/zero direction may fire when retail would never fire them. (doc anchor: "Divergences ranked" #5, L1268-1272) | Not re-verified this session — orthogonal to motion/position, low priority. | **COULD-NOT-VERIFY** | n/a |
| 6 | Legacy entity path has no hook dispatch (anim-hooks, LOW-MEDIUM), at old `GameWindow.cs:8563-8573`. (doc anchor: "Divergences ranked" #6, L1274-1277) | Not re-verified — line numbers are 2026-06-04-vintage and GameWindow.cs has grown/shifted substantially (now 13,759 lines vs whatever it was then); the cited range is very likely stale. | **STALE-ANCHOR** (line numbers unverified/likely shifted; concept not re-checked) | n/a |
| 7 | `enter_default_state` not called on spawn (MotionInterp, LOW). No `InitializeMotionTables`/pending_motions Ready-node seeding at spawn. (doc anchor: "Divergences ranked" #7, L1279-1282) | Grepped `MotionInterpreter.cs` for `enter_default_state`/`EnterDefaultState`/`InitializeMotionTables` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs` (no match) |
| 8 | `StandingLongJump` path not called per-tick (MotionInterp, LOW). The standing-long-jump pre-jump pose sub-state exists as a flag but `apply_interpreted_movement`'s per-tick branch (Ready + StopSideStep) is not wired. (doc anchor: "Divergences ranked" #8, L1284-1287) | `StandingLongJump` field exists and is set/cleared in a few places (`MotionInterpreter.cs:328,941,1119,1146`) but no per-tick `apply_interpreted_movement`-style dispatch was found calling `DoInterpretedMotion(Ready)` + `StopInterpretedMotion(SideStepRight)` from the flag. Also confirmed D3 in doc 2 (MoveToState longjump bit) is now WIRED on the *outbound* wire side (`standingLongjump` param exists in `MoveToState.Build`), but that's the wire byte, not the per-tick animation branch this claim is about — those are different things. | **STILL-TRUE** (the per-tick animation branch is still not called; note the wire-side `standingLongjump` param now exists as an input but nothing sets it to true from a live per-tick standing-longjump detection) | `src/AcDream.Core/Physics/MotionInterpreter.cs:328,941,1119,1146` (flag only, no per-tick apply path); cf. `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70` (wire param exists but is a pass-through, always `false` by default) |
| 9 (supporting) | Remote-entity path dispatches via direct `SetCycle` call from `GameWindow.OnLiveMotionUpdated`, NOT via `move_to_interpreted_state` (executive summary + divergence #5/#9 in the MotionInterp section, L303-317). | Confirmed: `move_to_interpreted_state`/`MoveToInterpretedState` still does not exist anywhere except one comment reference. `OnLiveMotionUpdated` still directly manipulates `ae.Sequencer` (`SetCycle`) and does a raw bulk-copy into `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` — bypassing `adjust_motion` for remotes entirely. This is THE central mechanism relevant to the walk<->run-lag symptom: the D6-ported `adjust_motion`/`apply_raw_movement`/`get_state_velocity` triad is **local-player-only** (see doc-3 cross-check below). | **STILL-TRUE** (confirmed, and directly load-bearing for the task's (a)/(c) focus) | `src/AcDream.App/Rendering/GameWindow.cs:4517` (comment only ref to move_to_interpreted_state); `GameWindow.cs:4590,4598` (direct bulk-copy into InterpretedState, bypassing adjust_motion); `GameWindow.cs:4327-4492` (SetCycle-direct dispatch) |
| 10 (supporting) | acdream's remote velocity path bypasses `apply_current_movement` gating differences: acdream calls `PhysicsObj.OnWalkable` gate directly rather than the full retail contact/gravity flow (MotionInterp section divergence #3, L305). | `apply_current_movement` (used by BOTH local and remote via `rm.Motion.apply_current_movement` calls) still gates on `PhysicsObj.OnWalkable` directly and calls `get_state_velocity()` — same shape as described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:890-910`; called for remotes at `GameWindow.cs:10042,10074` |
---
## Doc 2: 2026-06-26-movement-animation-retail-parity-audit.md (D1-D12)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| D1 | MoveToState raw flags not retail — presence-based instead of default-difference packing; overs-sends default values. (doc L118-130) | `MoveToState.cs` doc header now explicitly says "default-difference flags dword + conditional fields + actions" and `RawMotionState.Default` exists with retail defaults (`CurrentHoldKey=None`, `ForwardCommand=0x41000003`, `ForwardSpeed=1.0`, etc. matching doc's retail defaults table L60-72). Shipped as part of L.2b (`78e163a4`, 2026-06-30). | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/MoveToState.cs:22`; `src/AcDream.Core/Physics/RawMotionState.cs:44-58` |
| D2 | RawMotionState action-list and style packing incomplete — no full action-list packing, current-style scenarios. (doc L132-136) | `RawMotionState` now has `Actions` (IReadOnlyList<RawMotionAction>) with a doc comment describing retail's `num_actions` bits 11-15 packing. Appears substantially addressed as part of L.2b, though full conformance vs the bitfield layout (11 one-bit flags + num_actions:5) was not independently re-verified byte-for-byte this session. | **CLOSED-SINCE** (structure now present; exact bit-packing not re-verified) | `src/AcDream.Core/Physics/RawMotionState.cs:57-63` |
| D3 | MoveToState longjump bit not modeled — only contact 0/1 passed, `standing_longjump` not wired as named state. (doc L138-147) | `MoveToState.Build` now has a `standingLongjump` parameter and packs `trailing = (byte)((standingLongjump ? 0x02 : 0) \| (contact ? 0x01 : 0))` — matches doc's described retail byte layout. NOTE: the parameter is wired as an INPUT but this session did not verify any call site sets it to `true` from a live per-tick standing-longjump detection (see doc-1 item #8 above — the per-tick animation branch driving this flag is still missing). So the WIRE FORMAT is fixed; whether it's ever populated correctly is a separate, still-open question. | **CLOSED-SINCE** (wire format only; upstream detection still absent) | `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70,100-101` |
| D4 | JumpAction packet layout retail-incompatible — missing full Position, extra objectGuid/spellId fields, wrong order. (doc L149-161) | `JumpAction.cs` doc header now cites `Position::Pack (0x005a9640)` and explicitly notes the fix: "the pre-slice code had it backwards (two spurious trailing zero u32s, no Position at all)" — confirms this was found and fixed as part of L.2b. | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/JumpAction.cs:21-31` |
| D5 | Position heartbeat close but not fully proven — `NotePositionSent` stamps all three (time/position/contact-plane) on BOTH MTS and AP paths, but retail's `SendMovementEvent` (MTS) only stamps `last_sent_position_time`; only `SendPositionEvent` (AP) stamps all three. (doc L163-169) | Confirmed STILL live and explicitly re-audited: a 2026-06-30 dated comment at the MTS send site says verbatim "this is a real, audit-confirmed divergence from retail... left UNCHANGED; reported to the lead engineer instead of fixed here." This is the most concrete, freshest confirmation in the whole audit — it was re-checked via Ghidra decompile-by-address and deliberately NOT fixed. | **STILL-TRUE** (explicitly re-confirmed and deliberately deferred, not a stale claim) | `src/AcDream.App/Rendering/GameWindow.cs:8320-8339` |
| D6 | MotionInterpreter lacks canonical retail raw→interpreted normalization (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement` not ported); SideStepLeft/WalkBackward produce zero velocity; jump lateral velocity hand-patched. (doc L171-182) | **This is exactly what Doc 3 (D6 pseudocode doc) documents as SHIPPED** for the local player. Confirmed present: `adjust_motion` (`MotionInterpreter.cs:741`), `apply_run_to_command` (`:805`), `apply_raw_movement` (`:848`), `get_state_velocity` (`:639`) all exist and are called from `PlayerMovementController.cs:909,974,1014`. | **CLOSED-SINCE** (for LOCAL player only — see D7 below, this does NOT extend to remotes) | `src/AcDream.Core/Physics/MotionInterpreter.cs:639,741,805,848`; `src/AcDream.App/Input/PlayerMovementController.cs:880-1014` |
| D7 | Animation application split from retail motion flow — `AnimationSequencer` has useful pieces but `MotionInterpreter.apply_current_movement` is velocity-oriented and doesn't drive animation state through retail's order (style/forward/sidestep/turn/actions sequencing). (doc L184-188) | **THE central claim for the task's (a)/(c) focus.** Confirmed still true and now MORE precisely characterized: for remotes, `OnLiveMotionUpdated` bulk-copies `ForwardCommand`/`ForwardSpeed` directly into `InterpretedState` (bypassing `adjust_motion`), AND separately calls `ae.Sequencer.SetCycle(...)` directly for the visual cycle — two parallel, only loosely-coupled paths (one drives position/velocity via `apply_current_movement``get_state_velocity`, the other drives the visual cycle via `SetCycle`). Neither goes through `apply_interpreted_movement`'s full style/forward/sidestep/turn sequencing. Additionally there is a SEPARATE `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` mechanism (UP-velocity-derived, with a 200ms UM grace window + 4.5/5.5 m/s hysteresis bucketing) that ALSO writes `SetCycle` for the local-player-observed-remotely case — this is a THIRD parallel path, not documented in either research doc, that is very likely a major contributor to "walk<->run reacts too slowly" (its grace window + hysteresis band means a walk<->run toggle needs velocity to cross a threshold via UpdatePosition samples before the visual cycle re-buckets, since a UM may not arrive for HoldKey-only toggles per the code's own comments). | **STILL-TRUE** (confirmed and refined — root mechanism now identified precisely, more detailed than the original doc knew) | `src/AcDream.App/Rendering/GameWindow.cs:4590,4598` (bulk-copy bypass); `GameWindow.cs:5112-5176` (`ApplyServerControlledVelocityCycle`); `GameWindow.cs:5178-5300+` (`ApplyPlayerLocomotionRefinement`, grace/hysteresis constants at `:5095,5104,5110`) |
| D8 | Force-walk and MoveTo are approximations — `ServerControlledLocomotion`, `RemoteMoveToDriver`, `PlayerMovementController.BeginServerAutoWalk` approximate steering/cycle selection vs retail's full `MoveToManager` queue (pre-turn, move, aux turn, final heading, sticky targeting, progress failure, `MovementParameters::get_command`). (doc L190-196) | `ServerControlledLocomotion.cs` is still only 87 lines (a `PlanMoveToStart`/`PlanFromVelocity` helper, not a queued state machine). No pre-turn/aux-turn/sticky-target/progress-failure machinery found. | **STILL-TRUE** | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines total); `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines, still a simplified steering helper) |
| D9 | Inbound movement types 8 (TurnToObject) and 9 (TurnToHeading) dropped — `UpdateMotion` handles interpreted state and MoveTo 6/7 but not 8/9. (doc L198-207) | Confirmed: `UpdateMotion.TryParse` only branches on `movementType == 0` and `movementType is 6 or 7`; no `8`/`9` case anywhere. Grep for `TurnToObject`/`TurnToHeading` across `UpdateMotion.cs` and `GameWindow.cs` returns zero hits. The 2026-07-02 handoff doc explicitly reconfirms this live: "CONFIRMED live: mt=0x09 arrives and just SetCycle(Ready) instead of turning." | **STILL-TRUE** (independently re-confirmed via grep + corroborated by a live capture cited in the handoff doc) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:136-238` (no case 8/9) |
| D10 | Spawn-time movement state weaker than live — `CreateObject` detects movement types/flags but doesn't preserve full MoveTo target/origin/threshold data the way `UpdateMotion` does. (doc L209-213) | Confirmed with exact detail: `CreateObject.TryParseMoveToPayload` (private, 3-out-param overload: `movementParameters`, `speed`, `runRate`) SKIPS Origin/distanceToObject/minDistance/failDistance/walkRunThreshold/desiredHeading via `pos +=` advances with no output — so `CreateObject`'s returned `ServerMotionState.MoveToPath` is always `null`. By contrast `UpdateMotion.TryParseMoveToPayload` (a DIFFERENT, more complete overload) DOES populate `out CreateObject.MoveToPathData? path` with Origin/thresholds. Two parsers of the same wire shape, one materially weaker. | **STILL-TRUE** (confirmed with precise mechanism: two divergent parser overloads for the same MoveTo payload) | `src/AcDream.Core.Net/Messages/CreateObject.cs:1194-1226` (weak 3-field overload, spawn path) vs `src/AcDream.Core.Net/Messages/UpdateMotion.cs:255-331` (full path-populating overload, live-update path) |
| D11 | Sequence/autonomy data parsed then discarded — retail carries movement sequence, server-control sequence, autonomous state, motion flags, position sequence into movement application; acdream exposes only a subset. (doc L215-219) | Confirmed: `UpdateMotion.TryParse` still does a blind `pos += 6` skip over `movementSequence`(u16) + `serverControlSequence`(u16) + `isAutonomous`(u8) + align — none of these three fields are captured into the `Parsed` record or `ServerMotionState`. (Note: `WorldSession.cs` DOES track `_serverControlSequence`/`_instanceSequence`/etc., but those are the OUTBOUND sequence counters sourced from `UpdatePosition`, not the INBOUND per-entity `UpdateMotion` fields this claim is about — different mechanism entirely, do not conflate.) `_motionFlags` (byte) is read into a local var and never used beyond that line either. | **STILL-TRUE** (confirmed, and clarified that the WorldSession sequence counters some might assume closes this are unrelated) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:89-106` (skip), `:111` (`_motionFlags` read, unused past declaration) |
| D12 | Jump/falling/contact gates simplified — retail allows specific movement while falling/dead, has separate jump checks for posture/stamina/constraints/pending-motion/contact/leave-hit-ground reapplication; acdream blocks or simplifies several. (doc L221-225) | Not exhaustively re-verified line-by-line this session (large surface). Spot-checked `contact_allows_move`/`motion_allows_jump`-equivalent: the Fallen/Dead/Crouch block still lives inside `contact_allows_move` (see doc-1 item #2), which is itself evidence the gates remain simplified/misplaced relative to retail's `DoMotion`-level error codes. No `pending_motions`-driven jump eligibility check found (consistent with item #1). | **STILL-TRUE** (partially re-verified via the contact_allows_move overlap with doc-1 #2; full jump/posture/stamina gate audit not redone) | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (same evidence as doc-1 #2) |
---
## Doc 3: 2026-07-01-d6-motion-interp-pseudocode.md — cross-check of its own claims
| Claim | Current-code check | Status |
|---|---|---|
| "D6 ships `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/`get_state_velocity` in `MotionInterpreter.cs`, unifying LOCAL player velocity+turn+jump+wire onto one `RawMotionState` at `forward_speed=1.0`" | Confirmed via git log (`0f099bb6` D6.2a, `d34721fa` D6.2b) and via direct code read — all four functions exist and are wired into `PlayerMovementController.cs` only. | **STILL-TRUE (as scoped)** |
| "Out of scope for D6: RawMotionState unification — retail uses ONE raw state for both the wire and the velocity pipeline; D6 keeps the L.2b wire construction separate... unifying is a later slice" (doc L153-156) | This scoping statement is itself accurate and still holds: the wire-side `RawMotionState`/`MoveToState.Build` path and the local velocity-side `apply_raw_movement` input are DIFFERENT `RawMotionState` construction sites (confirmed by design decision #1 in the doc itself, "ONE `RawMotionState`... is built from `MovementInput`" — but this unification was the D6.2 GOAL for local-only; there is still no unification with the REMOTE/inbound path at all). | **STILL-TRUE (scoping honored, and the doc's own "later slice" is exactly the gap this task's handoff doc targets)** |
| "Out of scope for D6: turn ... acdream keeps direct-Yaw turn is replaced" / actually turn WAS ported per design decision #3 ("Turn ported to interpreted, feel unchanged... omega.Z = ±(π/2) × turn_speed... the fixed TurnRateFor direct-Yaw is replaced by the pipeline") — LOCAL only. | For remotes, `RemoteMoveToDriver.TurnRateFor` is still the mechanism (per doc's own "AP-9 stays" note) — confirmed no remote-side `adjust_motion`-sourced turn omega found; `RemoteMoveToDriver.cs` (340 lines) still exists with its own turn-rate logic, separate from `MotionInterpreter`. | **STILL-TRUE (remote turn is still the old direct-rate approximation; local turn was ported per D6 but that's a different code path)** |
---
## Synthesis: what this means for the walk<->run lag + stop-slide symptom (task focus a/b)
1. **D6 (the shipped port) does not touch the remote/inbound path at all.** Every function it
ported (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, `get_state_velocity`)
is called exclusively from `PlayerMovementController.cs` (local player). Zero call sites
from `GameWindow.cs`'s remote-entity code (`OnLiveMotionUpdated`, `_remoteDeadReckon`,
`ApplyServerControlledVelocityCycle`) invoke these D6-ported functions.
2. **The remote path bulk-copies wire commands directly into `InterpretedState`**
(`GameWindow.cs:4590,4598`), bypassing `adjust_motion` — so backward/sidestep-left
normalization and run-rate scaling never happen for remotes the retail-faithful way.
This matches the retail `copy_movement_from` bulk-copy semantics for THAT ONE STEP, but the
surrounding `pending_motions`/queue/`move_to_interpreted_state` state machine around it
(doc-1 items #1, #9) is still entirely absent.
3. **A newly-identified (not named in either doc) mechanism likely explains "reacts too
slowly":** `ApplyPlayerLocomotionRefinement` (`GameWindow.cs:5178+`) only kicks in after a
`UmGraceSeconds = 0.2` (200ms) window since the last UM, and then applies **hysteresis**
bands (`PlayerRunPromoteSpeed = 5.5f`, `PlayerRunDemoteSpeed = 4.5f`) before re-bucketing
walk↔run from UpdatePosition-derived velocity — this is architecturally exactly the kind of
mechanism that would produce a visible lag on a walk↔run toggle, especially combined with
the code's own comment that retail may not broadcast a fresh MoveToState for HoldKey-only
toggles (Shift while W held), forcing reliance on this slower velocity-inference path.
4. **D9 (types 8/9 dropped)** means any server-driven turn command mid-locomotion is silently
turned into a `SetCycle(Ready)`, which would visibly interrupt/desync locomotion — a
plausible contributor to "compounding error... running around + turning."
5. **D11 (sequence numbers discarded)** means acdream has no way to detect/reject
out-of-order or duplicate `UpdateMotion` packets, which is a plausible contributor to the
"position errors once the entity stops" (a late/duplicate packet could re-apply a stale
state after a newer one, with no sequence check to catch it).
None of the above is a fix recommendation — this is a raw claim/verification map only, per
the report-only investigation mode.

View file

@ -0,0 +1,969 @@
# Retail decomp map: INBOUND remote-entity motion pipeline
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (66 MB, Sept 2013
EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that
file (not addresses), from a Sept-2026 checkout at
`C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`.
Addresses (0x0051xxxx etc.) are also given per function for cross-reference with
`symbols.json` / Ghidra.
---
## Q1 — INBOUND ENTRY: wire message -> motion interpreter
Full call chain, outermost (network) to innermost (motion-table state machine):
```
ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0)
switch (opcode) {
case 0xf619: // "Movement" — the live/current movement update
SmartBox::UnpackPositionEvent(...) line 357142
if result == NETBLOB_PROCESSED_OK:
CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690)
if nonzero: cmdinterp->LoseControlToServer()
case 0xf74c: // position+movement combo, has an extra u16 seq check first
is_newer(obj->update_times[8], seq) gate line 357224
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232
}
```
`CPhysics::SetObjectMovement` (2-arg overload used for 0xf619/0xf74c dispatch,
`__stdcall`, line 271370 / addr 0x00509690):
```
int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
u16 seqA, u16 seqB, bool isAutonomous)
{
isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();
// 16-bit wraparound-aware "is this sequence number newer" compare,
// done TWICE against two independent counters obj->update_times[1]
// and obj->update_times[5]:
diff = |seqA - obj->update_times[1]| (mod 0x10000)
newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule
if not newer: return 0 // STALE PACKET, DROPPED
obj->update_times[1] = seqA
diff2 = |obj->update_times[5] - seqB|
newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
if not newer2: return 0 // STALE, DROPPED
obj->update_times[5] = seqB
if (!isAutonomous || !isPlayer) { // remote entity ALWAYS
// takes this branch;
// local player only
// takes it when NOT
// self-driving (server
// override / rubber-band)
obj->last_move_was_autonomous = isAutonomous
CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742)
if isPlayer: return 1 // signals caller to call LoseControlToServer
}
return 0
}
```
`CPhysicsObj::unpack_movement` (line 280179, addr 0x00512040):
```
void unpack_movement(CPhysicsObj* this, buf**, bufLen)
{
if (this->movement_manager == null)
this->movement_manager = MovementManager::Create(this, this->weenie_obj)
MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202
}
```
`MovementManager::unpack_movement` (line 300563, addr 0x00524440) — deserializes
the wire struct and dispatches to ONE of 10 sub-cases (`command_ids[ecx_4]` type
tag read from the first u16 in the buffer, case 0..9 via jump table @300707):
```
switch (type_tag) {
case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS
// is the walk/run/turn/sidestep command path used for remote
// players AND monsters
InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606
// optional trailing u32 = "sticky" target object id
MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c)
if sticky_id != 0: CPhysicsObj::stick_to_object(...)
motion_interpreter->standing_longjump = (type_tag & 0x200)
return 1
case 6: // MoveToObject
Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
motion_interpreter->my_run_rate = <wire float>
CPhysicsObj::MoveToObject(physics_obj, target_guid, &params) line 300644
case 7: // MoveToPosition
similar; CPhysicsObj->my_run_rate set from wire; then
MoveToManager::MoveToPosition(...) line 300659
case 8: // TurnToObject
case 9: // TurnToHeading
-> MoveToManager::TurnToHeading / handled via MoveToManager
}
```
**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0
(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant
path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape
as the client's own `RawMotionState`/interp state, containing
`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`,
`turn_command/speed`, plus a list of pending server "actions"
(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement,
NOT used for normal walk/run).
`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170):
```
void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
{
if (motion_interpreter == null) {
motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
CMotionInterp::enter_default_state(motion_interpreter)
}
CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272
}
```
`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE
entry point that turns a wire InterpretedMotionState into an actual motion-table
transition:
```
int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
{
if (physics_obj == null) return 0
this->raw_state.current_style = ims->current_style
CPhysicsObj::interrupt_current_movement(physics_obj)
bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES
// forward/side/turn
// command+speed wholesale,
// line 293301
CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949
// then replay any queued server "actions" (jump charge etc.) whose
// action_stamp is newer than server_action_stamp — sequence-wraparound
// compare identical in shape to the SetObjectMovement 0x7fff test above
for (action in ims->actions) {
if (newer(action.stamp, this->server_action_stamp)) {
this->server_action_stamp = action.stamp
CMotionInterp::DoInterpretedMotion(this, action.motion, &params)
}
}
return 1
}
```
**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the
InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*,
current_style) — there is no "diff the old vs new command" step here.** The
actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN,
inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` ->
`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new
`forward_command`/`forward_speed`.
---
## Q2 — TRANSITION: walk<->run while already moving
`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870):
```
void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
{
if (physics_obj == null || !initted) return
isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path
return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path
}
```
`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous`
(set by `SetObjectMovement` above — for a genuinely-remote object this flag is
always false relative to the LOCAL viewer, so remote players/monsters always
take the `apply_interpreted_movement` branch.)
`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the
per-command dispatcher that turns the bookkeeping InterpretedMotionState fields
back into individual `DoInterpretedMotion` calls:
```
void apply_interpreted_movement(CMotionInterp* this, int a, int b)
{
if physics_obj == null: return
if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate
DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first
if (!contact_allows_move(this, interpreted_state.forward_command)) {
DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
} else if (standing_longjump) {
DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
} else {
DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND
if interpreted_state.sidestep_command == 0:
StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
else:
DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
}
if interpreted_state.turn_command != 0:
DoInterpretedMotion(this, interpreted_state.turn_command, {})
return // early return — no idle-stop check runs this frame
if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
add_to_queue(this, ctx=0, READY_STANCE, tickCount)
}
```
So a wire "run instead of walk" update decays into exactly one
`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})`
call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local
input path, not a special "speed-changed" fast path at this layer.
`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360):
```
uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
{
if physics_obj == null: return 8
if (contact_allows_move(this, motion)) {
if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
ApplyMotion-only path)
if motion == 0x40000011 /* some "cancel" motion */:
CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims
result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager
if result == 0:
jumpAllowed = ...
add_to_queue(this, p->context_id, motion, jumpAllowed)
if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
} else if (motion & 0x10000000) == 0:
label_528440:
if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
result = 0
else:
result = 0x24 // motion rejected (e.g. mid-air command not allowed)
if (physics_obj != null && physics_obj->cell == 0)
CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard
return result
}
```
`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs
a `MovementStruct{type=InterpretedCommand}` and calls
`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls:
```
if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in
// pending_animations
// (DIFFERENT list
// from CMotionInterp's
// pending_motions!)
```
`CMotionTable::DoObjectMotion` is a thin wrapper for
`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`.
### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860)
This is the true state machine that decides append-vs-replace-vs-fast-path. Given
`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`:
**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands
carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):**
```
cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):
// *** THE SAME-CYCLE FAST PATH ***
if (new_substate == state->substate // SAME logical
&& same_sign(new_speed, state->substate_mod) // command (walk OR run
&& CSequence::has_anims(sequence)) { // stays walk, or run stays
// run — direction unchanged)
// AND a cycle is already
// playing
change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate
subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution
combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution
state->substate_mod = new_speed
return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode
// keeps playing; only its playback-rate + the CSequence's
// cached velocity/omega vectors change.
}
// *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
// substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
linkAnim = CMotionTable::get_link(table, state->style, state->substate,
state->substate_mod, new_substate, new_speed)
if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
// no direct link authored, OR direction reversed: route through the
// style's registered "default"/rest substate as an intermediate hop
defaultSubstate = style_defaults[state->style]
linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
}
CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7
CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s)
add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s)
add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state)
add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic
state->substate = new_substate
state->substate_mod = new_speed
CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep)
// on top of the new chain
*outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
return 1
```
**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a
speed change does **NOT always reuse the same cycle**. It depends on whether the
new command maps to the SAME `substate` id as the currently-playing one:
- Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 ->
WalkForward speed 1.0, or RunForward at any two different `forward_speed`
values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/
`combine_motion` — same `AnimSequenceNode` object, just re-timed and
re-weighted. No new node, no restart.
- **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs
`0x44000007` RunForward are different substate ids)**, so it does NOT hit
the fast path. It goes through the **link-transition path**: `get_link`
looks up an authored transition animation (a short blend clip, e.g.
walk-to-run or run-to-walk) between the two substates; that link node(s)
are appended to the sequence via `add_motion`, followed by the new cyclic
node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then
proceeds: link anim plays first (non-cyclic, finite frames), and once it
completes the `CSequence::update_internal` advance mechanism moves
`curr_anim` forward in the `anim_list` to the next node — the new cyclic
walk/run anim — automatically (see Q4).
**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a
doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single
"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line
301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and
`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the
cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims
everything from `first_cyclic` onward when a new transition starts (so
`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim
that's mid-playback [it's before first_cyclic], but throw away the old loop").
`curr_anim` points at the node currently being played; `CSequence::update`
advances `frame_number` within `curr_anim` and, in `apricot()`, walks
`curr_anim` forward through the list once frames are exhausted for a node,
discarding fully-consumed non-cyclic nodes from the front of the list up to
`first_cyclic`.
**Sub-question: is there blending?** No cross-fade/blend in the graphics sense.
It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
codebase means the `CSequence.velocity`/`omega` accumulators (float vectors)
are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion`
add or subtract scaled contributions) — that's a physics-level blend of
velocity, not a skeletal pose blend.
**Sub-question: is there an immediate speed change?** Only in the same-substate
fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen
synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire
message is processed — no interpolation of speed itself). For walk<->run
(different substate), the VISIBLE speed change is gated behind the link anim's
playback duration — velocity is whatever `CSequence.velocity` currently holds
(the link anim's own authored velocity/omega, added via `add_motion`), and only
once the cyclic node becomes current does the full run/walk cyclic velocity
apply.
### `same_sign` (line 298253, addr 0x00522260) — verbatim
```
int same_sign(float a, float b) {
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
// this is the "is direction unchanged" test used to gate the same-cycle
// fast path and to decide whether get_link needs a sign-aware lookup.
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
}
```
### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant
```
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
else if (fabs(newSpeed) >= 0.000199999995f)
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
// else: both ~0, no-op
}
```
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
to every node from `sequence->first_cyclic` onward
(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed
change (same substate) scales animation playback speed proportionally to the
commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new
multiplier is negative (playing the cycle backward).
---
## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
**There are TWO distinct pending-queues, easy to conflate:**
1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields:
`[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`.
Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) —
called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion`
(line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775),
and `StopCompletely` (line 305227). Popped ONLY by
`CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0):
```
void MotionDone(CMotionInterp* this, int arg2) {
if (physics_obj == null) return
head = pending_motions.head_
if (head != null) {
if (head->motion & 0x10000000) { // this queued motion carried
// a server "action" (jump
// charge etc.)
CPhysicsObj::unstick_from_object(physics_obj)
InterpretedMotionState::RemoveAction(&interpreted_state)
RawMotionState::RemoveAction(&raw_state)
}
pop head off pending_motions (delete node)
}
}
```
`CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`.
**`context_id`/`action_stamp` on this queue is used for jump-charge and
other server-acknowledged "actions", NOT for ordinary walk/run — ordinary
`DoInterpretedMotion` calls still push a node here (so `motions_pending()`
reflects "any interpreted motion is mid-flight"), but nothing about
walk<->run reads the `context_id`/action semantics.**
2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`,
fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter`
(running decrement counter). Owner: `MotionTableManager` (one per
`CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame
-level completion tracker, distinct from #1's motion-command-level tracker).
Appended by `MotionTableManager::add_to_queue` (line 290854, addr
0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks`
value it returned (how many more discrete animation "steps"/frames worth of
non-cyclic content remain before this motion is fully consumed). Also
immediately calls `remove_redundant_links` (line 290771) to prune
already-queued-but-superseded link-transition entries (see Q2 note on
walk<->run spam).
**Consumption / popping — TWO drivers:**
- **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line
290645, addr 0x0051be00), called every physics tick via
`CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for
`CheckForCompletedMotions`, line 290845) from
`CPhysicsObj::UpdateObjectInternal` (line 283748). Walks
`pending_animations` from the head while `tickCount == 0`, firing
`CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and
removing action-heads (`MotionState::remove_action_head`) if the
`0x10000000` bit is set.
- **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr
0x0050fda0) — registered as a `CAnimHook` fired by
`CSequence::execute_hooks` (line 300780) when a specific animation FRAME
carries a hook whose `direction_` matches playback direction. Calls
`CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)`
(line 290558, addr 0x0051bce0), which increments `animation_counter` and
pops every `pending_animations` entry whose `tickCount <= animation_counter`
(decrementing the counter by each popped entry's `tickCount`, i.e. a
running-total consumption model, not a strict per-frame countdown).
- **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line
306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw
input path (`MovementManager::PerformMovement` cases 0-4) — calls
`CPhysicsObj::CheckForCompletedMotions` immediately after every
`DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/
`StopCompletely` dispatch (line 306234/241/248/255/262), so a
zero-duration motion completes in the SAME frame it was issued rather
than waiting for the next tick. This path is NOT used by the wire/remote
entry (`CMotionInterp::apply_interpreted_movement` calls
`DoInterpretedMotion`/`StopInterpretedMotion` directly without a following
`CheckForCompletedMotions` — the remote entity therefore only gets its
completions serviced by the per-tick poll and the anim-hook path, not the
synchronous one).
`CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr
0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) ->
`CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is
the bridge between the two queues**: a `MotionTableManager`-level
animation-frame completion cascades UP into popping the
`CMotionInterp`-level command queue.
**Callback / state change on completion:** popping `pending_motions` only (a)
optionally clears the "stuck to object" state + removes a pending server action
if the popped node had the `0x10000000` "carries an action" bit, and (b) frees
the node. It does NOT itself touch velocity, `substate`, or the `CSequence`
node list — those were already mutated synchronously back when
`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time).
**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`)
is what actually matters for gameplay feel: it's what lets a queued
non-cyclic link anim naturally hand off to the next queued node (see Q4) and
what lets a "jump" or other single-shot server action be acknowledged as
finished.**
---
## Q4 — CYCLE SWAP: frame index carryover vs restart vs link
Per `CSequence::append_animation` (line 301777, addr 0x00525510):
```
void append_animation(CSequence* this, AnimData* animData) {
node = new AnimSequenceNode(animData)
if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip
DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL
this->first_cyclic = node // *** every appended node
// becomes the new
// first_cyclic marker
// until superseded ***
if (curr_anim == null) { // sequence was idle/empty
curr_anim = anim_list.head_
frame_number = curr_anim->get_starting_frame()
}
// if curr_anim was already non-null (something mid-playback), it is
// left untouched — the newly appended node just waits at the tail.
}
```
So **appending never resets `frame_number` for whatever's currently playing.**
The frame index of the CURRENTLY playing node (the link anim, or the old cycle
if it's still `curr_anim`) is untouched.
`CSequence::update` (line 302402, addr 0x00525b80):
```
void update(CSequence* this, double dt, Frame* outDelta) {
if (anim_list.head_ != null) {
CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
CSequence::apricot(this) // list-trim housekeeping (below)
} else if (outDelta != null) {
CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration,
// no animation nodes at all
}
}
```
`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in
this decompile (unresolvable float compares/branches show as raw
`/* unimplemented */` FPU op comments) — the BN decompiler could not fully
recover its control flow. What IS recoverable: it advances `frame_number`
within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's
frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)`
(confirmed indirectly via `apricot`'s cleanup logic below and via
`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on
"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping
`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list
node when frames are exhausted).
`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every
`update()`:
```
void apricot(CSequence* this) {
i = (anim_list.head_ != null) ? adjustedHead : null
if (i != curr_anim) {
while (i != first_cyclic) {
// unlink node i from anim_list (both directions), delete it,
// then advance i to the new head
... unlink + delete ...
i = new head
if (i == curr_anim) break
}
}
}
```
i.e., **once `curr_anim` has moved past the head of the list (a node finished
playing), `apricot` deletes every now-stale node from `anim_list.head_` up to
(but not including) `first_cyclic`.** This is standard "consume finished
one-shot link anims off the front of the queue" behavior.
**Direct answer:** the frame index does **NOT carry over between the OLD cycle
and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping
different `CAnimation` data with independently-tracked start frames
(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue
smoothly is:
- the **link animation plays out fully first** (its own authored frame range,
from `get_starting_frame()` to its end), because it was appended to the
tail and `curr_anim` only advances once the current node's frames are
exhausted (via `update_internal`'s internal advance, not `apricot`, which
is just cleanup).
- once the link anim's frames are exhausted, playback naturally proceeds to
the next node in `anim_list` (the newly appended cyclic walk/run node),
which starts fresh at ITS `get_starting_frame()`.
- So the transition literally IS the link animation: **walk -> run uses an
authored transition clip in between**; there is no cross-fade of the walk
cycle's frame position into the run cycle's frame position. The retail art
pipeline authors these link/transition clips specifically so this hard
swap looks continuous.
- If `get_link` found NO authored link for this style/substate pair (the
`linkAnim == null` branch in `GetObjectSequence`), the code instead hops
through the style's "default" (idle/ready) substate as an intermediate —
two link anims chained — rather than doing a raw cut.
- `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the
same-substate fast path) operates on `this->first_cyclic` onward, i.e., it
re-times whatever is the CURRENT cyclic node in place — it does not touch
frame_number's absolute position within that node, only its rate of
advance, so a walk-speed-change (not walk<->run) preserves the current
frame's phase, just plays faster/slower/backward from there.
---
## Q5 — POSITION DRIVE between inbound packets
Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` ->
`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`:
```
CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10
{
... skip if parented/no-cell/frozen ...
dt = Timer::cur_time - this->update_time
if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed
if dt < 2.0:
UpdatePositionInternal-chain for the whole dt in one call
else:
// clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
// then one final UpdateObjectInternal(remainder) call — prevents a
// huge single-frame teleport after e.g. a stall/loading hitch
while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
UpdateObjectInternal(this, remaining)
}
CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0
{
... early-outs for ethereal/off-world states, still runs particle/script update ...
if (this->cell != 0) {
var deltaFrame = {identity}
UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move
if (has spheres / real collision geometry) {
if (deltaFrame == zero-delta) {
set_frame(this, &deltaFrame); cached_velocity = 0
} else {
heading update (velocity-derived or state-flag-derived)
CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL
// COLLISION
// SWEEP,
// same
// machinery
// as local
// player
// movement
if (result == null) {
set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied??
// (this branch means find_valid_position failed to
// produce a transition object; effectively a no-collision
// passthrough for objects without real spheres)
cached_velocity = 0
} else {
cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION
// velocity, NOT the
// raw commanded one
SetPositionInternal(this, result)
}
}
} else {
// no collision spheres on this part array: apply frame directly, no sweep
set_frame(this, &deltaFrame); cached_velocity = 0
}
DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
(== MotionTableManager::UseTime
== CheckForCompletedMotions) /
PositionManager::UseTime
}
}
CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30
{
if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta
// via CSequence::update
if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
// (server position-correction blend, see Q6)
Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta
if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top
CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
}
CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0
{
CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4:
// animation-node-consumption path OR
// pure apply_physics(velocity*dt) fallback
}
```
**Direct answer: BOTH, and they are the SAME code path, not two competing
sources.** `CSequence::update` chooses between:
(a) **animation-node consumption** (`update_internal`) when `anim_list` is
non-empty — this advances frames AND, per-node, the per-frame position
delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`)
contributes to the produced `outDelta` Frame (the x87-obscured part of
`update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame`
/ `get_part_frame` existing specifically to fetch per-frame authored
pose+position data), and
(b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity;
outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a
pure-interpreted-velocity idle/moving state with no queued transition
animations left) — this is the steady-state "walking/running in a
straight line between server packets" case for a LOOPING cyclic anim once
its own list bookkeeping considers it "done producing new nodes" — but
note `has_anims()` / `anim_list.head_ != null` is true whenever there's
ANY node (including the still-looping cyclic one), so in practice, for a
normal walk/run cycle, path (a) is what's active essentially always;
path (b) is the true-idle / "no motion data at all, just raw velocity"
fallback (e.g. after `StopCompletely` clears everything, or for
objects that were never given a motion table).
Either way, the output Frame delta is what feeds `Frame::combine` against the
CURRENT position, and the combined candidate then goes through the FULL
`CPhysicsObj::transition` collision sweep — remote entities are
collision-checked every tick exactly like the local player, they are not
simply "teleported" along a straight line. `cached_velocity` (used for e.g.
UI/physics queries, NOT for driving the next tick's move — the next tick
re-derives everything from `CSequence` state) is the ACTUAL post-collision
displacement/dt, which can differ from the commanded interpreted velocity if
a wall was hit.
---
## Q6 — CORRECTION: reconciling inbound position updates
Two independent correction paths were located; both are called from
`UpdatePositionInternal`/its callers, gated by whether the wire message
carried a full `Position` update or just a motion-command update:
1. **`PositionManager::adjust_offset`** (called every tick from
`UpdatePositionInternal`, line 280857) — blends a stored "we're behind
where we should be" offset into the per-tick delta over time, i.e. a
position-manager-owned soft-correction/interpolation smoothing layer
(`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/
`IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations —
all wrapped 1:1 through `CPhysicsObj::unstick_from_object`,
`StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`,
`get_sticky_object_id`). The named-retail decompile does not expose
`PositionManager::adjust_offset`'s internal body in this file (its class
implementation lives outside the traced call chain reached in this pass);
what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates
the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update`
just wrote, i.e. it's a correction applied ON TOP OF the
animation/velocity-driven delta, before that delta is combined with
current position and swept for collision. This is the retail equivalent of
"dead-reckoning error absorbed gradually into the next frame's move" rather
than a hard position snap.
2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion
command but also a fresh authoritative `Position` (the position+movement
combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases
6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr
0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/
`SetScatterPositionInternal` machinery — this is a direct authoritative
`Position` set (through `AdjustPosition` + `CheckPositionInternal` +
`handle_all_collisions`), i.e. a hard reposition/snap when the server
sends a full position rather than only a motion-state delta. `unpack_movement`
case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run)
does NOT carry a `Position` at all — it only ever updates the motion
command/speed and lets local dead-reckoning (`CSequence`-driven `update` +
collision sweep, per Q5) carry the position forward until the next
authoritative position or motion packet arrives. There is no visible
"snap-if-error-exceeds-threshold" constant found in the traced functions
in this pass — the correction is structurally continuous
(`adjust_offset` blended every tick) rather than threshold-triggered,
based on what's directly observable in this file.
---
## Q7 — STOP: motion -> ready/stand
Stopping is **not special-cased outside the normal `GetObjectSequence`
machinery** — it is routed through the exact same link-transition logic as any
other substate change, targeting the style's registered idle/rest substate.
Entry points, both eventually reaching `CMotionTable::StopSequenceMotion`
(line 298954, addr 0x00522fc0):
```
CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470
-> if contact_allows_move fails OR standing_longjump-with-jump-motion:
just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
-> else:
CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
-> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
-> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
-> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command
// back to 0x41000003 READY
```
`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0):
```
int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
*outTicks = 0
if ((motion & 0x40000000) != 0 && motion == state->substate) {
// stopping the MAIN cycle (forward walk/run, not a modifier like
// sidestep): look up the style's default (idle/ready) substate and
// re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
// to idle", full link-anim machinery applies (Q2/Q4)
defaultSubstate = style_defaults[state->style]
return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
}
if ((motion & 0x20000000) != 0) {
// stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
// the base cycle rather than replacing it): find the modifier's
// MotionData and directly SUBTRACT its velocity/omega contribution
for (m in state->modifier_head-list) {
if (m.motion == motion) {
modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
if (modData != null) {
subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega
// subtraction, NO link anim,
// NO node changes — this IS
// how e.g. releasing sidestep
// while still running removes
// just the sideways component
MotionState::remove_modifier(state, m, prev)
return 1
}
break
}
}
}
return 0
}
```
**Velocity zeroing:** happens in TWO places depending on stop type:
- Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s
link-transition branch, which unconditionally calls
`CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle
chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes
`sequence->velocity` and `sequence->omega` outright, then the new link
anim's OWN authored velocity/omega (if any) is added back via
`add_motion`. So there IS a hard zero, immediately followed by
re-population from the transition-to-idle clip's own baked
velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
- Modifier stop (sidestep/turn release): `subtract_motion` directly removes
exactly that modifier's contribution (scaled by its `speed_mod`) from the
still-nonzero base-cycle velocity — no full zero, no `clear_physics` call,
because the base cycle (e.g. still running) keeps its own velocity intact.
**Stop/link animation:** YES — the idle-entry is itself an authored `get_link`
transition clip from the current substate to the style's default substate,
exactly like any other substate-to-substate transition (Q2/Q4). There is no
"instant freeze frame"; retail plays a deceleration/stop clip.
**Residual-sliding prevention:** because `clear_physics` zeroes
`sequence->velocity`/`omega` at the moment the stop-transition is initiated
(not merely when the stop ANIMATION finishes), the `apply_physics`/animation
per-frame delta stops contributing translation from THAT frame onward except
whatever the stop-link-clip's own authored motion data supplies via
`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no
"physics keeps sliding while the stop anim plays" bug window — the only motion
during the stop-link clip is whatever the clip's OWN keyframed velocity says
(typically small/decelerating by design), and once the link clip's frames are
exhausted and playback reaches the (typically near-static) idle cyclic node,
velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)`
contributed (near zero for a proper "Ready"/idle motion).
Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld`
which is really "flush the motion table manager's queued link anims", called
from multiple guard points: whenever `physics_obj->cell == 0` inside both
`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`,
`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly)
provides a hard-reset safety valve: if the object leaves the world/cell
mid-transition, any queued link-transition chain is discarded outright rather
than left dangling.
---
## Verbatim float constants collected in this pass
| Constant | Where | Meaning |
|---|---|---|
| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. |
| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. |
| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. |
| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). |
| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. |
| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. |
| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. |
| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. |
---
## Function/line index (quick lookup for a synthesis pass)
| Symbol | Line | Addr | Role |
|---|---|---|---|
| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards |
| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray |
| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** |
| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test |
| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale |
| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup |
| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) |
| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update |
| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions |
| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward |
| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null |
| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command |
| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion |
| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
---
## Notes on scope / what was NOT fully resolved
- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number`
advances, exact interpolation between `low_frame`/`high_frame`, and the
precise mechanism by which a per-frame authored position delta from
`AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is
x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU
compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather
than resolved C. This matches the documented project-wide limitation (see
`memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain
section) that some floating-point-heavy retail functions don't fully
decompile via Binary Ninja and may need a cdb live-trace or manual
disassembly pass to pin exact behavior. What IS certain from the
surrounding code (append_animation/get_pos_frame/get_part_frame/apricot)
is the STRUCTURE: node-list advance + per-node authored frame data feeding
the output Frame.
- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was
not located inside this pseudo-C excerpt in this pass — only its call site
and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`,
`IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep
for `PositionManager::` method bodies (likely a different source file /
address range not covered by the anchors given) would be needed to get its
exact blend formula and any snap-threshold constant.
- No explicit "snap if error > threshold" constant was found for position
correction in the portions traced; the retail design as observed here is a
continuous per-tick blend (`adjust_offset`) plus occasional authoritative
hard `SetPositionInternal` when the wire message actually carries a
`Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a
distance-threshold-triggered snap layered on top of ordinary motion-command
packets.

View file

@ -0,0 +1,156 @@
# Handoff: inbound animation + position — verbatim retail port
Date: 2026-07-02
Branch: `claude/vigorous-joliot-f0c3ad` (clean; prior arc all committed through `d34721fa`)
Mode for the new session: **/investigate first (report-only), then port on approval.**
## What just shipped (context — the OUTBOUND / LOCAL half is done)
This session made acdream's *outbound* wire and *local-player* motion
retail-faithful, verified against live ACE + user/retail-observer sign-off:
- **L.1b** `2c8620ea` — dual `IMotionCommandCatalog` (AceModern runtime /
Retail2013 conformance, full `command_ids[0x198]` extraction).
- **L.2b** `78e163a4` + sign-off `f271a49e` — outbound wire parity (D1
default-difference `RawMotionState::Pack`, D3 contact/longjump byte, D4
`JumpPack`).
- **D6** `4ed27836` (pseudocode) + `0f099bb6` (D6.2a) + `47407506` (sign-off) +
`d34721fa` (D6.2b) — ported `CMotionInterp::adjust_motion` /
`apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` and
unified the local player's velocity + turn + jump + wire onto one
input-built `RawMotionState` at `forward_speed=1.0`. Backward/strafe-left no
longer zero; strafe retail-exact; turn omega from interpreted `turn_speed`.
- **Key wire finding:** ACE **recomputes** the broadcast run speed from run
skill (echo-test confirmed) and auto-upgrades `WalkForward+HoldKey.Run →
RunForward`. The wire sends raw `forward_speed=1.0`. (Crib:
`claude-memory/project_retail_motion_outbound.md`.)
**That was OUTBOUND + LOCAL. This handoff is the INBOUND / REMOTE half.**
## The goal
Port retail's **incoming (inbound) handling of animation + position VERBATIM**
into acdream — but **surgically**, targeted at where acdream deviates (not a
blind wholesale sequencer rewrite; a rushed sequencer swap has "broken
everything" in a past session — integrate carefully). Strict standard: no
approximations; retail decomp / ACE / DAT as oracles; parity tests before
behavior changes.
## The symptom (the acceptance oracle — user's live observation)
Watching a **remote player** (inbound motion) in acdream:
- Overall "works OK" but **not polished**. Some **sliding** and some
**position errors once the entity stops**.
- **The worst offender:** transitioning **walk↔run WITHOUT stopping** — the
motion **interpreter reacts too slowly**, and this **throws all animation +
position off afterward**, compounding as the entity keeps **running around +
turning**. The error accumulates through subsequent motion.
Fixing the walk↔run-transition responsiveness (and the resulting animation +
position desync) IS the acceptance test.
## Root-cause hypothesis (UNCONFIRMED — verify in the investigation first)
acdream's motion sequencer does not implement retail's motion-**transition**
state machine. Retail's `CMotionInterp` doesn't just swap the animation cycle
on a motion change — it **appends** the new motion to a `CSequence` and
transitions through it with `pending_motions` / `MotionDone` bookkeeping. If
acdream applies a walk↔run change abruptly/late (snap `SetCycle`) instead of
sequencing it, the animation cycle and the dead-reckoned position drift apart
and the error accumulates. The 2026-06-04 deep-dive already ranked missing
`pending_motions` / `MotionDone` as **HIGH**.
## Pre-paid research to leverage (READ THESE)
1. **`docs/research/2026-06-04-animation-sequencer-deep-dive.md`** — START
HERE. Retail `CMotionInterp` + `CSequence` + hook-dispatch state graph + 8
ranked divergences (missing `pending_motions`/`MotionDone` HIGH; frame-swap
class; link→cycle boundary). **Verify its findings against the CURRENT code
— the L.1 animation work may have closed some gaps since 2026-06-04; do not
trust its line numbers.**
2. `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the
inbound divergences: **D7** (animation split from retail motion flow), **D8**
(force-walk / `MoveToManager` approximate), **D9** (inbound `TurnToObject`/
`TurnToHeading` types 8/9 dropped — CONFIRMED live: `mt=0x09` arrives and
just `SetCycle(Ready)` instead of turning), **D10** (spawn-time MoveTo weak),
**D11** (sequence/autonomy parsed then discarded), **D12** (jump/contact
gates simplified).
3. `docs/research/2026-07-01-d6-motion-interp-pseudocode.md` — the OUTBOUND/local
`CMotionInterp` port already done; inbound uses the SAME machinery
(`get_state_velocity`, `adjust_motion`, `apply_raw_movement` now exist in
`src/AcDream.Core/Physics/MotionInterpreter.cs`).
## Decomp anchors (the oracle; grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
- `CMotionInterp::apply_current_movement` (~line 305713 — sequences style /
forward-falling / sidestep / turn / actions), `DoInterpretedMotion` (~305575),
`CMotionTable::GetObjectSequence` (~298636).
- `CSequence` append/advance/update (~lines 301622, 301777, 301839, 302425).
- The 2026-06-04 deep-dive has the full CMotionInterp/CSequence address list.
- `acclient.h` for the structs; Ghidra HTTP bridge at `http://127.0.0.1:8081`
(decompile-by-address) if it's up.
## Where the inbound code lives (starting points to map)
- `src/AcDream.App/Rendering/GameWindow.cs` — inbound remote-entity motion:
`OnLiveMotionUpdated`, the remote `SetCycle` / `ObservedOmega` seeding
(~4815-4856), dead-reckoned position + omega application (~9750-9865), the
`[SETCYCLE]`/`[SCFAST]`/`[SCFULL]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/
`[UPCYCLE]` diagnostics.
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — the cycle/action sequencer.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — interpreted state (now has
the D6 `adjust_motion`/`apply_raw_movement`/`get_state_velocity`).
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` — remote MoveTo + `TurnRateFor`.
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`, `UpdatePosition.cs`,
`MoveToState.cs` — inbound parsing.
## The plan for the new session
1. **/investigate (report-only):** produce a ranked **deviation map**
where acdream's inbound animation + position handling deviates from retail's
`CMotionInterp`/`CSequence`, **anchored on the walk↔run transition-lag
symptom** (not a whole-system rewrite). Parallel research agents are a good
fit. Optionally corroborate with a live capture (below). Deliver the report
+ recommended verbatim-port slices; get explicit approval before edits.
2. **Port (after approval):** verbatim-port the deviating retail mechanisms —
most likely the `CSequence` motion-queue / transition + `pending_motions` /
`MotionDone`, and the inbound position/dead-reckoning where it deviates.
Tests-first (parity tests before behavior). **Integrate the sequencer
SURGICALLY** — change the minimum; do not replace the whole transform/cycle
pipeline at once.
3. **Verify:** the walk↔run transition is smooth and no cascading
animation/position desync — user visual + retail-observer side-by-side.
## Live-capture setup (structured testing)
- acdream = **OBSERVER** (login as `+Acdream`); the **other player driven from a
retail client** (retail packets = ground truth, so any render artifact is a
pure acdream inbound bug).
- Launch env (PowerShell): the standard live vars +
`ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1` (heavy; dumps
`[UM_RAW]`/`[SETCYCLE]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/`[UPCYCLE]` so
you can correlate each inbound packet with what acdream renders).
- Structured protocol, one motion at a time: walk-straight (start→stop),
run-straight, turn-in-place, curved path, idle, **walk↔run toggle
(the key one)**, abrupt-stop-from-run.
- NOTE: `+Acdream` had `run=15225` (GM-buffed) this session → absolute speeds
unrepresentative; judge **relative** behavior + transitions.
## Housekeeping
- **Close the running observer acdream client before the new session rebuilds**
(it locks `AcDream.App.exe`; `dotnet build` fails with the DLL-copy lock
otherwise — the user closes it, we do NOT kill it).
- A background research agent (mapping acdream's inbound animation handling) was
in-flight when this session stopped — orphaned; the new session re-does the
mapping cleanly.
## Discipline reminders
- The user's side-by-side retail observations are **axioms** (retail-oracle);
once a mechanism plan exists, artifacts become acceptance tests, not new
investigations (no whack-a-mole).
- Report-only until fixes approved. Decomp-verbatim. Tests-first.
- Every deviation the port introduces/retires updates
`docs/architecture/retail-divergence-register.md` in the same commit.

View file

@ -0,0 +1,55 @@
# R1-P0 — pseudocode pins and ambiguity resolutions
The verbatim extraction lives in `r1-csequence-decomp.md` (1,756 lines, per-
function raw pseudo-C + cleaned translation, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This note records
the corrections and pins the R1 port codes against, per the gap map
(`r1-gap-map.md`).
## Corrections to the extraction (verified against raw decomp + ACE)
1. **§21 `update_internal` cleaned flow is garbled in two places.** The
authoritative skeleton is ACE `Sequence.cs:351-443` (verified verbatim,
matches the raw decomp branch layout at 0x005255d0-0x005259ca):
overshoot check → clamp `frame_number` to `get_high_frame()` (fwd) /
`get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone = true` → per-frame crossing loop → `if (!animDone) return`
AnimDone gate (`anim_list.head != first_cyclic`) →
`advance_to_next_animation` → carry leftover → loop.
2. **Hook direction constants**: forward crossings pass +1
(0x0052578c, counter `++` after), reverse pass 1 (0x0052590c, counter
`--` after). Retail encoding == ACE/DatReaderWriter `AnimationHookDir`
(Backward=1 / Both=0 / Forward=1) — no remap.
## PINNED ambiguity — leftover-time carry after node advance
Raw decomp at 0x0052598a-0x0052598d APPEARS to zero the elapsed argument
after `advance_to_next_animation` before looping; ACE carries the leftover
(`timeElapsed = frameTimeElapsed`, Sequence.cs:436-442).
**PIN: the ACE reading (carry the leftover).** Rationale: (a) Binary Ninja
routinely loses x87 stack-slot reassignments (the same artifact class as the
garbled setcc returns in `is_newer`); (b) zeroing would make a lag spike
unable to fast-forward through multiple queued nodes in one tick, visibly
freezing links under hitches — behavior nobody has ever reported on retail;
(c) the loop structure (`while(true)` with the leftover recomputed per
node) only makes sense with a carry.
**Confirmation pending (not blocking):** cdb breakpoints on
`acclient!CSequence::update` + `acclient!CSequence::advance_to_next_animation`
with hit counters (pattern `tools/cdb/l2g-observer.cdb`) under an induced
stall — an advance/update ratio > 1 in a single update proves the carry.
Fold into the next live retail session; if it DISPROVES the carry, the fix
is one line in `update_internal` + rerun of the P4 conformance suite.
## P0 addenda for later stages
- The same cdb session should also capture `append_animation` /
`remove_cyclic_anims` argument logs (P2/P5 goldens) — one session serves
all of R1.
- `frame_number` is x87 `long double` (acclient.h:30747); C# `double` is the
closest available → divergence-register row lands with the P2 commit
(G15).
- ACE's `PhysicsGlobals.EPSILON` subtraction in
`get_starting/get_ending_frame` is an ACE fabrication (compensating for
ACE's `float FrameNumber`) — retail returns bare ints. Do NOT copy (G1).

View file

@ -0,0 +1,377 @@
# acdream AnimationSequencer — current-state map (R1)
File: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines, read whole).
Companions: `AnimationCommandRouter.cs` (98 lines), `AnimationHookRouter.cs` (95
lines), `IAnimationHookSink.cs` (89 lines) — all read whole.
## 0. Type inventory in AnimationSequencer.cs
- `IAnimationLoader` — abstraction (`LoadAnimation(uint id) : Animation?`) so
the sequencer can be unit-tested without a real `DatCollection`.
- `DatCollectionLoader : IAnimationLoader` — production impl, wraps
`DatCollection.Get<Animation>(id)`.
- `PartTransform` (readonly struct) — `Vector3 Origin`, `Quaternion
Orientation`. Output unit of `Advance`.
- `AnimNode` (internal sealed class) — one queue entry:
`Animation Anim; double Framerate; int StartFrame; int EndFrame; bool
IsLooping; bool HasPosFrames; Vector3 Velocity; Vector3 Omega`.
Methods: `MultiplyFramerate(factor)`, `GetStartFramePosition()`,
`GetEndFramePosition()`. `FrameEpsilon = 1e-5` (mirrors retail
`_DAT_007c92b4`).
- `AnimationSequencer` (public sealed class) — the engine itself, one
instance per entity.
## (a) Feature matrix — AnimationSequencer vs retail CSequence concepts
| Retail concept | Retail anchor cited in acdream | acdream implementation | Notes / fidelity |
|---|---|---|---|
| Node list (AnimSequenceNode queue) | FUN_00525EB0 `advance_to_next_animation`; ACE `Sequence.cs` | `LinkedList<AnimNode> _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList<T>`. Non-cyclic head (link frames) + looping tail (`_firstCyclic..end`) invariant maintained explicitly rather than via a node flag walked at runtime. |
| Link resolution (`get_link`) | ACE `MotionTable.cs:395-426`; retail `CMotionTable::GetObjectSequence` 0x00522860 | `GetLink(style, substate, substateSpeed, motion, speed)` private method | Forward-direction path: `Links[(style<<16)|substate][motion]`, then style-level catch-all `Links[style<<16][motion]`. **Reversed-direction branch** added (comment cites "K-fix6"): when either speed is negative, looks up `Links[(style<<16)|motion][substate]` instead (link FROM motion TO substate, played reversed) — handles WalkBackward/SideStepLeft/TurnLeft transitions. Also a `StyleDefaults` fallback under the reversed branch. This 2-branch structure is **acdream's own generalization**, not a literal 1:1 decompile citation (no direct FUN_xxx cited for the branch split itself, only for its constituent parts via ACE line numbers). |
| `adjust_motion` (TurnLeft/SideStepLeft/WalkBackward → mirror) | ACE `MotionInterp.cs:394-428` | Inlined at top of `SetCycle` + duplicated in `HasCycle`: 0x000E→0x000D (negate speed), 0x0010→0x000F (negate speed), 0x0006→0x0005 (negate speed × 0.65 `BackwardsFactor`) | Faithful port; the `0x65` low-byte switch is applied to `motion & 0xFFFFu`, i.e. matched purely on the low 16 bits regardless of class byte. |
| Fast-path re-speed (no restart on same motion) | ACE `MotionTable.cs:132-139` | `SetCycle` early-return branch: if `CurrentStyle==style && CurrentMotion==motion && sign(speedMod)==sign(CurrentSpeedMod)``MultiplyCyclicFramerate` instead of rebuild | Faithful. Explicit **sign-flip exception** documented: when adjust_motion flips speedMod's sign while `motion` value itself stays the same (WalkForward with negative speed = backward), the fast path is bypassed by the sign check so a full restart occurs — comment marks this as an acdream-observed necessity (2026-05-02), not literally cited to a retail address. |
| `multiply_cyclic_animation_framerate` | FUN_00525CE0; ACE `Sequence.cs L277-L287` | `MultiplyCyclicFramerate(float factor)` — walks `_firstCyclic..end`, calls `AnimNode.MultiplyFramerate` on each, and also scales `CurrentVelocity *= factor; CurrentOmega *= factor` | Faithful for node framerates. The velocity/omega scaling is justified by algebraic equivalence to ACE's `subtract_motion(old)+combine_motion(new)` (`MotionTable.change_cycle_speed`, `MotionTable.cs L372-L379`) rather than being separately decompiled — an acdream derivation, not a citation of a specific retail scaling line. |
| `GetStartFramePosition` / `GetEndFramePosition` | FUN_00526880 / FUN_005268B0 | `AnimNode.GetStartFramePosition()/GetEndFramePosition()` | Faithful 1:1 including the `EPSILON = _DAT_007c92b4` (hardcoded here as `1e-5` — the retail exact float constant was **not** independently verified against the binary in this file's comments; it's asserted equal). |
| `multiply_framerate` (StartFrame↔EndFrame swap for negative speed) | FUN_005267E0 | `AnimNode.MultiplyFramerate(factor)` | **Explicitly documented divergence**: retail swaps StartFrame↔EndFrame for negative factor; acdream keeps `StartFrame ≤ EndFrame` as an invariant and encodes direction purely via `Framerate`'s sign, compensating in the `Advance` loop's boundary checks instead. Comment states this is valid "because the callers we care about... only ever pass positive factors" — i.e. an acknowledged simplification with a stated (unverified beyond code review) precondition. |
| `update_internal` (per-frame advance loop) | FUN_005261D0; ACE `Sequence.cs:351-443` | `Advance(float dt)` | Faithful structurally: `while (timeRemaining>0 && _currNode!=null)` loop (capped `safety=64`**acdream-invented safety valve**, no retail citation, guards against a "degenerate motion table" infinite loop). Computes `delta = rate*timeRemaining`; forward vs reverse branches each: (1) detect boundary overflow, (2) clamp `_framePosition` to boundary-epsilon, (3) walk every integer frame crossed applying `ApplyPosFrame` + `ExecuteHooks`, (4) on wrap, call `advance_to_next_animation` (`AdvanceToNextAnimation()`) and continue with leftover `overflow` time. |
| `advance_to_next_animation` (node wrap) | FUN_00525EB0 | `AdvanceToNextAnimation()` — moves `_currNode` to `.Next`, or wraps to `_firstCyclic` if null, else holds on last node if no cyclic tail exists | Faithful. Resets `_framePosition` via `GetStartFramePosition()` on transition. |
| `execute_hooks` | ACE `Sequence.cs:262-270` | `ExecuteHooks(node, frameIndex, playbackDir)` — fires hook if `hook.Direction == Both \|\| hook.Direction == playbackDir` | Faithful 1:1 port of the direction-match condition. |
| Root motion (`AFrame.Combine` / `frame.Subtract`) | ACE (`AFrame.Combine`/`Subtract`, cited generically, no line #) | `ApplyPosFrame(node, frameIndex, reverse)` — forward: `_rootMotionPos += Rotate(pf.Origin, _rootMotionRot); _rootMotionRot = Normalize(_rootMotionRot * pf.Orientation)`; reverse: conjugate-then-subtract | Faithful port of the two composition directions. Accumulated into `_rootMotionPos`/`_rootMotionRot`, drained via `ConsumeRootMotionDelta()`. |
| AnimationDone hook on link drain | ACE `PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook)` (cited generically) | `AnimationDoneSentinel` (static `AnimationDoneHook{Direction=Both}`) pushed to `_pendingHooks` in `Advance` just before `AdvanceToNextAnimation()`, gated on `!_currNode.Value.IsLooping` | Faithful concept; the sentinel object is a single shared static instance (not per-fire), meaning downstream code cannot distinguish *which* motion completed purely from hook identity — must correlate via entity + timing. Not flagged as a bug in the file; simply a design note for consumers. |
| Quaternion slerp | FUN_005360d0 (chunk_00530000.c:4799-4846) | `SlerpRetailClient(q1, q2, t)` static method | Faithful port including retail's odd step-5 validation-then-linear-fallback quirk (`SlerpEpsilon = 1e-4f` near-parallel check, then acos/sin slerp, then re-validate blend weights ∈[0,1] before trusting the slerp result — falls back to linear otherwise). Explicitly documented as differing from "the standard formula" only in this validation step. |
| Frame-boundary blend (`BuildBlendedFrame`) | Not directly cited to a single retail FUN — described as producing "the current blended keyframe" | `BuildBlendedFrame()` — clamps `frameIdx` to `[rangeLo,rangeHi]`; computes `nextIdx` stepping in playback direction; **wraps only if `curr.IsLooping`**, else holds boundary frame | **Explicitly acdream-motivated fix, not retail-cited**: comment for issue **#61** ("door swing-open flap; run-stop twitch") states holding the boundary frame instead of wrapping-blending into frame 0 for one-shot (non-looping) nodes avoids "a brief flash through the anim's starting pose at the link→cycle boundary." This is the **frame-swap / link→cycle boundary flash class of bug** the deep-dive skill targets — currently patched empirically here, not derived from a decompiled retail boundary-hold mechanism. |
| Placement / root frames | `AnimData.AnimId`, `Animation.PartFrames`, `Animation.PosFrames`, `Frame.Origin/Orientation` (DatReaderWriter types) | `LoadAnimNode` reads `anim.PartFrames.Count` for frame bounds; `hasPosFrames = anim.Flags.HasFlag(PosFrames) && anim.PosFrames.Count >= numFrames` | Sentinel resolution for `HighFrame == -1` ("all frames") ported from `MotionResolver.GetIdleCycle` (cited by name, not address). `if (low > high) high = low` guards a degenerate AnimData. |
| "Fix B" (#39, 2026-05-06) — cyclic→cyclic direct transition skips link | cdb live trace 2026-05-03 of a Walk→Run transition: `add_to_queue(45000005, looping=1)` then `add_to_queue(44000007, looping=1)` with `truncate_animation_list` never firing | In `SetCycle`: `IsLocomotionCycleLowByte` check on both `CurrentMotion` and new `motion`'s low byte (0x05/0x06/0x07/0x0F/0x10); if both are locomotion AND `_firstCyclic` exists, the just-enqueued link node is **removed from the queue** and `_currNode` is forced directly onto `_firstCyclic` | This is the most heavily-commented divergence-turned-fix in the file. Explicit warning left in comments: "Commit c06b6c5 (reverted in a2ae2ae) demonstrated that unconditionally skipping the link breaks all of these [Idle→cycle, Falling→Ready, pose-change links, combat substates]" — i.e. the fix is deliberately **scoped only to the locomotion-cycle subset**, confirmed retail-faithful via a live cdb capture (grep-named-first / attach-cdb workflow), not guessed. |
| K-fix18 — `skipTransitionLink` | Not a retail citation — an acdream product decision, explicitly labeled "K-fix18" in the doc comment | `SetCycle(..., bool skipTransitionLink = false)` param. When true: `linkData` forced null, AND (separately) the entire `_queue` is cleared / `_currNode`/`_firstCyclic` reset to null before rebuild | **Confirmed acdream-invented deviation** (not retail behavior) — used only for Falling-on-jump-start (`GameWindow.cs:4830`, `10201`) to avoid a ~100ms "stop running" pose delay before the fall animation engages. This should have (or needs to be checked against) a `retail-divergence-register.md` row per CLAUDE.md's mandatory bookkeeping rule — the file itself doesn't reference the register. |
| Stop-anim fallback (direction-agnostic settle) | Not retail-cited; acdream reasoning: "settle anim is direction-agnostic" | In `SetCycle`, if `linkData is null` after the primary lookup, retries `GetLink` with `CurrentMotion`'s low byte remapped 0x06→0x05, 0x10→0x0F, 0x0E→0x0D (peer forward/right substate) | Acknowledged as an acdream workaround for a `null linkData` gap when stopping from WalkBackward/SideStepLeft/TurnLeft — motivated by an observed visible glitch ("left leg twitches forward two times") rather than by a decompiled retail fallback path. |
| Stale-head handling | Comment: "For remote entities receiving many bundled UMs over time, this stale-head build-up was the root cause of..." | `preEnqueueTail` snapshot before `EnqueueMotionData` calls; after enqueue, `firstNew = preEnqueueTail?.Next ?? _queue.First`; `_currNode` is **force-set** onto `firstNew` (or `_firstCyclic` under the Fix-B branch) rather than left wherever it was | Acdream-diagnosed and acdream-authored fix (no retail citation) — addresses a structural bug where `_currNode` could remain parked on old non-cyclic head frames left over from a previous `SetCycle` call, which visually manifested as "transitions between cycles don't visibly switch the leg pose" for remote entities. Local player was unaffected because `PlayerMovementController` calls `SetCycle` frequently enough to keep the queue clean — this is called out explicitly as a per-entity-class asymmetry. |
| Velocity/Omega synthesis for locomotion & turn cycles | `CMotionInterp::get_state_velocity` (FUN_00528960 cited by address); `MotionInterpreter.RunAnimSpeed` etc (decompiled from `_DAT_007c96e0/e4/e8`); ACE's `omega.z = ±(π/2)×turnSpeed` (cross-checked against holtburger `motion_resolution.rs`) | Post-`EnqueueMotionData` block in `SetCycle`: switches on `motion & 0xFFu` for 0x05/0x06/0x07/0x0F/0x10, sets `CurrentVelocity` from `WalkAnimSpeed=3.12f` / `RunAnimSpeed=4.0f` / `SidestepAnimSpeed=1.25f` constants × `adjustedSpeed`; separately, if `CurrentOmega` is ~zero, synthesizes turn omega `±(π/2)*adjustedSpeed` for 0x0D/0x0E | **Explicitly justified divergence from literal dat content**: "the Humanoid motion table ships every locomotion MotionData with Flags=0x00 (no HasVelocity)" — i.e. the dat itself is silent on velocity for these cycles, and retail's *actual* body-physics source (`get_state_velocity`) is a separate C++ function not expressed as sequencer/dat state at all. acdream folds that separate retail mechanism into `AnimationSequencer.CurrentVelocity` as a pragmatic single surface for consumers (dead-reckoning, `get_state_velocity` Option-B). Comment explicitly documents an earlier bug: a gate of `if (CurrentVelocity.LengthSquared() < 1e-9f)` previously let dat-baked link velocity "win" over synthesis, breaking walk→run transitions — now **unconditionally overwritten** for known locomotion low-bytes. |
| `clear_physics` before rebuild | ACE `Sequence.cs L256-L260` | `ClearPhysics()` — zeroes `CurrentVelocity`/`CurrentOmega`, called from `SetCycle` before the enqueue chain | Faithful; matches retail's `sequence.clear_physics()` call before each `add_motion` chain (`MotionTable.cs L100-L101, L152-L153`). |
| `add_motion` velocity/omega replace-not-accumulate semantics | ACE `MotionTable.add_motion` (`MotionTable.cs L358-L370`) | `EnqueueMotionData`: `if (motionData.Flags.HasFlag(HasVelocity)) CurrentVelocity = vel;` (replace, not add) similarly for Omega | Faithful — comment explicitly notes this REPLACES not accumulates, and that the final value after link+cycle enqueue is the cycle's (last write wins), which dead-reckoning consumers rely on. |
| PlayAction / Modifier & Action-class overlay | ACE `MotionTable.GetObjectSequence` line 189-207 (Action, mask 0x10) and line 234-242 (Modifier, mask 0x20) | `PlayAction(uint motionCommand, float speedMod=1f)` — tries `GetLink` (Links dict) first for Action-mask commands, then falls back to `_mtable.Modifiers` dict (styled key then plain key) for Modifier-mask commands | Faithful dual-path lookup. Notes Jump (`0x2500003B`) has both Action+Modifier-looking bits but ACE treats it via the Modifier path specifically — cited as a cross-check against ACE, not an independent retail decompile of Jump's own class byte. Inserts new non-looping nodes via `_queue.AddBefore(_firstCyclic, n)` (or `AddLast` if no cyclic tail), and force-jumps `_currNode` to the first inserted node if the cursor was currently on the cyclic tail (so overlay actions play immediately instead of waiting for a cycle wrap). |
| `Sequence.Velocity`/`Sequence.Omega` sequence-wide mirror | ACE `Sequence.cs L127-130`; `MotionTable.add_motion` L358-370 | `CurrentVelocity`/`CurrentOmega` public properties, described in the doc comment as "not per-node" — reflects the **most recently added** MotionData's velocity×speedMod, even while an earlier link node is still playing visually | Faithful semantic port — explicitly documented gotcha: "while a link animation plays, the surfaced velocity is still the cycle's velocity." |
| `HasCycle` (retail cycle-existence probe) | Not a literal retail function name — acdream utility mirroring the head of `SetCycle`'s adjust_motion + cycle-key lookup | `HasCycle(uint style, uint motion) : bool` — duplicates the adjust_motion switch (0x000E/0x0010/0x0006) then checks `_mtable.Cycles.ContainsKey(cycleKey)` | Acdream-invented consumer helper (doc comment explains it exists so callers can fall back to a known-good motion instead of hitting `SetCycle`'s unconditional `ClearCyclicTail` on a missing cycle, which "leaves the body without any animation tail" — visible as "torso on the ground"). Not retail-cited as its own function. |
| Reset | Not retail-cited | `Reset()` — clears queue, hooks, root motion, resets Current* fields to defaults | Utility method for entity despawn/respawn recycling; no retail anchor given. |
| Diagnostics: `CurrentNodeDiag`, `FirstCyclicAnimRefHash`, `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]` logging | N/A — acdream-only | `CurrentNodeDiag` tuple property (AnimRefHash via `RuntimeHelpers.GetHashCode`, IsLooping, Framerate, StartFrame, EndFrame, FramePosition, QueueCount); `FirstCyclicAnimRefHash`; three `Console.WriteLine` blocks gated on `ACDREAM_REMOTE_VEL_DIAG=1`, throttled to 0.5s via `_lastSetCycleDiagTime` | Entirely acdream-invented instrumentation ("Commit A 2026-05-03" / D2/D3/D4 diagnostic labels) for the remote-motion debugging campaign. Per CLAUDE.md rule 5 (diagnostic owner classes), this is a per-call-site `Environment.GetEnvironmentVariable` read pattern flagged in CLAUDE.md itself as "tech debt; do not add more" — these predate that rule and haven't been migrated to a diagnostic-owner class. |
## (b) Public API surface — what consumers depend on
### Constructor
```
AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
```
All three args are `ArgumentNullException.ThrowIfNull`'d. Consumers must
supply a non-null `Setup`/`MotionTable`/loader — `RenderBootstrap.cs`'s
`SequencerFactory` local function has a 3-tier fallback chain (real
setup+mtable → real setup + empty `MotionTable()` → fully-empty
`Setup()`+`MotionTable()`+`NullAnimLoader()`) precisely because the ctor
won't accept nulls.
### Methods
- `bool HasCycle(uint style, uint motion)` — probe before calling `SetCycle`
to avoid the "torso on ground" missing-cycle collapse. Called from
`GameWindow.cs:3723/3728/3732/3824` (initial spawn cycle selection with
Run→Walk→Ready fallback chain) and `RemoteMotionSink.Commit()` (same
fallback pattern for remote UM-driven cycles, plus an `ACDREAM_REMOTE_VEL_DIAG`
`[HASCYCLE]` diagnostic).
- `void SetCycle(uint style, uint motion, float speedMod = 1f, bool
skipTransitionLink = false)` — the primary state-transition entry point.
Callers across the codebase:
- `GameWindow.cs:3751` — initial spawn cycle.
- `GameWindow.cs:3825` — initial cycle after a fallback check.
- `GameWindow.cs:4830` — Falling-on-jump-start with `skipTransitionLink:
true` (K-fix18).
- `GameWindow.cs:4936` (`ApplyServerControlledVelocityCycle`) — NPC/monster
remotes still on the legacy `ServerControlledLocomotion.PlanFromVelocity`
path (pre-S6 unification; comment says this path stays until "S6 unifies
all entity classes onto the CMotionInterp funnel").
- `GameWindow.cs:5155/5309/9817` — landing cycle / stop-to-ready transitions.
- `GameWindow.cs:10223` — local player's own `SetCycle` call (with sidestep
speed-scaling correction factor `WalkAnimSpeed/SidestepAnimSpeed*0.5`
documented against ACE's `MovementData.cs:124-131` wire formula, #45).
- `RemoteMotionSink.Commit()` (`src/AcDream.App/Rendering/RemoteMotionSink.cs:215`)
— remote **player** entities via the L.2g S2b `CMotionInterp` funnel path
(see below); this is the modern replacement for the legacy path above for
player remotes specifically.
- `AnimationCommandRouter.RouteFullCommand` — routes `SubState`-class
commands here (see router section below).
- `void MultiplyCyclicFramerate(float factor)` — not called directly outside
`AnimationSequencer.cs` in the searched consumer set (invoked internally
by `SetCycle`'s fast-path re-speed branch); no external call sites found
in `src/`.
- `IReadOnlyList<PartTransform> Advance(float dt)` — per-frame tick. Sole
call site: `GameWindow.cs:9876` inside the animated-entity tick loop
(`seqFrames = ae.Sequencer.Advance(dt)`), guarded by an
`ae.Sequencer is not null` branch; entities without a sequencer fall to a
"legacy path" (`ae.CurrFrame += dt * ae.Framerate` manual slerp, line
~9896).
- `IReadOnlyList<AnimationHook> ConsumePendingHooks()` — drained immediately
after `Advance` at `GameWindow.cs:9882`, fanned out per-hook to
`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)` (`AnimationHookRouter`,
which further fans out to registered `IAnimationHookSink`s).
- `(Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()` — no
call sites found in the searched `src/` consumer grep; root motion
(PosFrames) accumulation exists in the sequencer but nothing currently
drains/consumes it in production code (search of `ConsumeRootMotionDelta`
and `\.PlayAction\(` etc. above did not surface a caller — **this looks
like dead/unwired API surface**, worth flagging for the deep-dive).
- `void PlayAction(uint motionCommand, float speedMod = 1f)` — called from
`AnimationCommandRouter.RouteFullCommand` for `Action`/`Modifier`/
`ChatEmote` route kinds, and directly from `RemoteMotionSink.ApplyMotion`
for the same route kinds (overlay dispatch for remote entities).
- `void Reset()` — no external call site found in the searched consumer set.
### Properties (read by consumers)
- `CurrentStyle`, `CurrentMotion`, `CurrentSpeedMod` (uint/uint/float,
private-set) — read by `GameWindow.cs` (style fallback defaults `0x8000003Du`
NonCombat at lines 3723/4827/4919 etc.), `RemoteMotionSink` ctor (seeds
`_style` from `sequencer.CurrentStyle`), `RemoteMotionSink.Commit()`
(`_sequencer.CurrentMotion`/`CurrentSpeedMod` for diagnostic comparison),
`GameWindow.cs:4915` (`ApplyServerControlledVelocityCycle`'s
`IsRemoteLocomotion(currentMotion)` gate).
- `CurrentVelocity` / `CurrentOmega` (`Vector3`, private-set) — the most
cross-cutting output surface:
- `GameWindow.cs:9331-9334` — remote player per-tick body translation
(`seqVel`/`seqOmega` feed `PositionManager`-style catch-up/anim
composition; extensive comment block on lines 9310-9330 walks through
retail's `CPartArray::Update``PositionManager::adjust_offset`
`Frame::combine` per-tick pipeline this is meant to mirror).
- `PlayerMovementController.AttachCycleVelocityAccessor(() =>
playerSeq.CurrentVelocity)` (`GameWindow.cs:12917`) — wires the local
player's sequencer velocity into `MotionInterpreter.GetCycleVelocity`
as an override of the decompiled constant path (`RunAnimSpeed *
ForwardSpeed`), used because arbitrary creatures' MotionTables don't all
bake `Velocity=4.0` on RunForward the way Humanoid does.
- `MotionInterpreter.cs` — comments describe `CurrentVelocity` as "already
`MotionData.Velocity * speedMod`" (body-local) and reference it as the
accessor payload described above; also cross-checks
`AnimationSequencer.CurrentVelocity`/`CurrentOmega` doc pointers at
lines 347/376/632/662.
- `QueueCount` (int) — diagnostic only (`CurrentNodeDiag` tuple's last
field also duplicates it).
- `HasCurrentNode` (bool) — diagnostic; no external call site found in
the searched set beyond the type itself.
- `CurrentNodeDiag` / `FirstCyclicAnimRefHash` — consumed by
`GameWindow.cs:9863-9871` inside an `ACDREAM_REMOTE_VEL_DIAG`-gated
`[CURRNODE]` log block that compares `AnimRefHash` against
`FirstCyclicAnimRefHash` to detect whether the visible animation is
actually on the intended cyclic tail.
### Nested/companion API — `IAnimationLoader` / `DatCollectionLoader`
- `IAnimationLoader.LoadAnimation(uint id) : Animation?` — implemented in
production by `DatCollectionLoader` (wraps `DatCollection.Get<Animation>`),
constructed once in `RenderBootstrap.cs:138` (`animLoader`) and passed by
reference into every `SequencerFactory`-built sequencer; a
`NullAnimLoader` (referenced but not shown in this file — defined
elsewhere) is the last-resort fallback in `RenderBootstrap`'s factory.
## AnimationCommandRouter.cs — companion routing layer
Static class, no state. Cited retail anchors:
`CMotionTable::GetObjectSequence` 0x00522860,
`CMotionInterp::DoInterpretedMotion` 0x00528360, plus
`docs/research/deepdives/r03-motion-animation.md` section 3.
- `Classify(uint fullCommand) : AnimationCommandRouteKind` — classifies by
class-mask bits: `0x12000000`/`0x13000000` class → `ChatEmote`;
`ModifierMask=0x20000000``Modifier`; `ActionMask=0x10000000`
`Action`; `SubStateMask=0x40000000``SubState`; `0``None`; else
`Ignored`. (Note: checks Modifier before Action, meaning any command with
both bits set classifies as Modifier — matches the `PlayAction` comment
about Jump 0x2500003B having both bits but ACE treating it as Modifier.)
- `RouteWireCommand(sequencer, currentStyle, ushort wireCommand, speedMod)`
reconstructs the full 32-bit command via
`MotionCommandResolver.ReconstructFullCommand(wireCommand)` then delegates
to `RouteFullCommand`.
- `RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod)`
dispatch table: `Action`/`Modifier`/`ChatEmote` → `sequencer.PlayAction(fullCommand,
speedMod)`; `SubState` → `sequencer.SetCycle(currentStyle, fullCommand,
speedMod)`. Returns the classified `AnimationCommandRouteKind` to the
caller for logging/branching.
`AnimationCommandRouteKind` enum: `None, Action, Modifier, ChatEmote,
SubState, Ignored`.
Consumers: `RemoteMotionSink.ApplyMotion` calls `AnimationCommandRouter.Classify`
directly (to special-case Turn/Sidestep before falling through) and
`RouteFullCommand` for the overlay branch. No other call sites found in the
searched consumer set for `AnimationCommandRouter` itself (GameWindow.cs
appears to call `Sequencer.SetCycle`/`PlayAction` directly rather than
through the router in most of the enumerated call sites above — the router
is primarily the remote-entity/funnel-driven path's dispatch layer).
## AnimationHookRouter.cs / IAnimationHookSink.cs — hook fan-out layer
- `IAnimationHookSink.OnHook(uint entityId, Vector3 entityWorldPosition,
AnimationHook hook)` — single-method interface. `AnimationHook` and its
subclasses (`SoundHook`, `SoundTableHook`, `SoundTweakedHook`,
`CreateParticleHook`, `DestroyParticleHook`, `StopParticleHook`,
`CallPESHook`, `DefaultScriptHook`, `DefaultScriptPartHook`,
`AttackHook`, `ReplaceObjectHook`, `TransparentHook`, `LuminousHook`,
`DiffuseHook`, `ScaleHook`, `NoDrawHook`, `SetOmegaHook`,
`TextureVelocityHook`, `SetLightHook`, `AnimationDoneHook`) are all
**external types from `DatReaderWriter.Types`**, not defined in
acdream's own tree — they're dat-format hook payload classes. The doc
comment enumerates intended downstream routing (Phase E.2 audio / E.3
particles / E.4 combat dispatcher / renderer state mutation / UI
notifications) but this file itself contains no subsystem logic — it's
purely the interface contract + a `NullAnimationHookSink` no-op
singleton for tests/headless.
- `AnimationHookRouter : IAnimationHookSink` — composite/fan-out
implementation. `Register(sink)` / `Unregister(sink)` are lock-protected
(`_gate`), copy-on-write into a `IAnimationHookSink[]` array (idempotent
register — checks `ReferenceEquals` before adding). `OnHook` iterates a
snapshot array **without locking** (explicitly commented "no lock in the
hot path (render thread)") and wraps each sink's `OnHook` call in a
`try/catch` that **silently swallows all exceptions** ("one misbehaving
sink must not take down the entire animation tick... individual
subsystems can log their own errors internally") — this is a blanket
catch-and-discard, worth flagging against CLAUDE.md's "no workarounds"
spirit if a sink is silently failing (per the user's `feedback_logger_injection_for_silent_catches.md`
memory note: libs that silently catch+return null usually should inject
an `ILogger`; this router has no logger injection point at all).
`Sinks` property exposes a read-only snapshot for diagnostics/tests.
Only one production sink registration path was located via the router's own
file (none) — sink registration call sites (`_hookRouter.Register(...)`)
were not enumerated in this pass; the single confirmed dispatch call site
is `GameWindow.cs:9890` (`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)`).
## How MotionData/anim dat structures reach the sequencer
- **Setup** (`DatReaderWriter.DBObjs.Setup`) — supplies `Parts.Count` (used
by `Advance`/`BuildBlendedFrame`/`BuildIdentityFrame` to size the
`PartTransform[]` output) and `DefaultMotionTable` id (used by
`RenderBootstrap.SequencerFactory` to look up the paired `MotionTable`).
- **MotionTable** (`DatReaderWriter.DBObjs.MotionTable`) — three
dictionaries consumed directly:
- `Cycles : Dictionary<int, MotionData>` keyed by
`(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source,
read in `SetCycle`/`HasCycle`.
- `Links : Dictionary<int, MotionCommandData>` where
`MotionCommandData.MotionData : Dictionary<int, MotionData>` — the
transition-frame source, read in `GetLink` (both forward and
reversed-key branches) and in `PlayAction`'s Action-mask lookup.
- `Modifiers : Dictionary<int, MotionData>` — the overlay/action source
for Modifier-mask commands in `PlayAction`, tried with a styled key
first then a plain (unstyled) key.
- `StyleDefaults : Dictionary<MotionCommand, MotionCommand>` — used only
inside `GetLink`'s reversed-direction fallback branch.
- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims :
List<AnimData>`; `Velocity`/`Omega : Vector3`; `Flags :
MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether
`Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and
`PlayAction`.
- **AnimData**`AnimId : QualifiedDataId<Animation>` (cast to `uint` for
`IAnimationLoader.LoadAnimation`), `LowFrame`/`HighFrame` (sentinel
`HighFrame == -1` meaning "all frames", resolved in `LoadAnimNode`),
`Framerate` (float, multiplied by `speedMod` to produce the `double
Framerate` stored on `AnimNode`).
- **Animation** (loaded via `IAnimationLoader.LoadAnimation(animId)`) —
`PartFrames : List<AnimationFrame>` (drives `numFrames` bounds-clamping
in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`);
`PosFrames : List<Frame>` (root motion, gated by `Flags.HasFlag(PosFrames)`
AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`.
- **AnimationFrame**`Frames : List<Frame>` (one `Frame` per Setup part —
indexed by part index in `BuildBlendedFrame`'s `f0Parts`/`f1Parts`
lookups, defaulting to identity for parts beyond the frame's list length),
`Hooks : List<AnimationHook>` (read in `ExecuteHooks`).
- **Frame**`Origin : Vector3`, `Orientation : Quaternion` — the raw
per-part or per-posframe pose sample, lerped/slerped in
`BuildBlendedFrame` or combined in `ApplyPosFrame`.
Entry point that ties Setup+MotionTable+Loader together for a live entity:
`RenderBootstrap.cs`'s `SequencerFactory(WorldEntity e)` local function
(lines ~147-174, moved verbatim from the pre-extraction `GameWindow.cs`
~2306-2334 per its own comment) — looks up `Setup` via
`dats.Get<Setup>(e.SourceGfxObjOrSetupId)`, then `MotionTable` via
`dats.Get<MotionTable>(setup.DefaultMotionTable)`, falling back through
three tiers (real/real → real-setup/empty-mtable → empty/empty) so the
constructor's null-guards are never tripped for any spawned entity, even
ones missing dat data.
## (c) acdream-invented vs dat-driven — summary classification
**Purely dat-driven (faithful mechanical port of retail's node/frame model):**
- Node list structure, link resolution forward-path, `GetStartFramePosition`/
`GetEndFramePosition`, `multiply_framerate` semantics (modulo the
documented Start/EndFrame-swap simplification), `update_internal`'s
frame-boundary walk + hook firing + root-motion accumulation,
`advance_to_next_animation` wrap, `execute_hooks` direction matching,
the retail slerp, `clear_physics`/`add_motion` replace-semantics for
Velocity/Omega, PlayAction's Action/Modifier dual-path lookup.
**acdream-invented (no retail citation, or explicitly-flagged deviation/workaround):**
1. **K-fix18 `skipTransitionLink`** — product decision to skip the retail
transition-link pose for Falling-on-jump-start; not retail behavior.
2. **Fix B locomotion cyclic→cyclic link-skip** — scoped fix verified via
live cdb trace (so it IS retail-faithful for that subset), but the
*mechanism* (removing the enqueued link node, forcing `_currNode` onto
`_firstCyclic`) is acdream's own structural patch, not a literal port
of a retail function.
3. **Stale-head `_currNode` force-relocation** (`preEnqueueTail`/`firstNew`
tracking) — acdream bug-fix for a structural mismatch versus retail's
apparent behavior; no retail citation for the mechanism itself.
2. **Stop-anim fallback** (direction-agnostic settle-link retry) — acdream
workaround for a null-linkData gap.
5. **`GetLink`'s reversed-direction branch** — while individually justified
against an *observed* bug (X-key twitch), it's presented as acdream's
own two-branch generalization of `get_link`, cross-checked against ACE
line ranges rather than a single retail function decompile.
6. **CurrentVelocity/CurrentOmega synthesis for locomotion/turn cycles**
(`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f`
constants, turn `±π/2` synthesis) — folds a *separate* retail C++
mechanism (`CMotionInterp::get_state_velocity`, a physics-side
function) into the *sequencer's* velocity surface because the
Humanoid dat itself carries no baked velocity for these MotionData
entries. Functionally retail-faithful in intent but architecturally a
deviation (retail keeps this computation outside the Sequence object
entirely).
7. **BuildBlendedFrame's non-looping boundary-hold** (issue #61 fix) — an
empirically-motivated patch for the link→cycle boundary flash, without
a cited retail mechanism describing how the real client avoids this
flash. **This is exactly the class of bug the animation-sequencer
deep-dive skill was built to root-cause** — the current fix may be
masking rather than replicating retail's actual boundary behavior.
8. **`safety = 64` loop cap in `Advance`** — defensive engineering, no
retail citation.
9. **All `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]/[CURRNODE]` diagnostics**
acdream-only instrumentation, per-call-site env var reads (flagged by
CLAUDE.md itself as a tech-debt pattern that shouldn't be extended
further without promotion to a diagnostic-owner class per Code
Structure Rule 5).
10. **`AnimationDoneSentinel` as a single shared static instance** — works
for "some link just finished" notifications but can't carry per-fire
identity; not verified against how retail's `AnimDoneHook` actually
carries context.
**Unwired / apparently dead API surface found during this pass:**
- `ConsumeRootMotionDelta()` — no call site found in `src/` outside the
sequencer itself. Root motion (PosFrames) is accumulated every tick but
nothing drains it into entity placement.
- `Reset()` — no external call site found in the searched consumer set.
- `MultiplyCyclicFramerate` — only self-invoked by `SetCycle`'s fast path;
no direct external callers.
- `HasCurrentNode` — no external call site found beyond its own
declaration.
## Cross-references worth pulling into the deep-dive
- `docs/research/acclient_animation_pseudocode.md` — cited pseudocode doc
for FUN_005267E0/FUN_00525EB0/FUN_00526880/FUN_005268B0/FUN_005360d0/
FUN_005261D0 (sections 5-7).
- `docs/research/deepdives/r03-motion-animation.md` section 3 — cited by
`AnimationCommandRouter.cs` for the class-mask routing scheme.
- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs` and
`MotionTable.cs` — the ACE C# port cross-referenced throughout
(`Sequence.cs:127-130, 256-260, 262-270, 277-287, 351-443`;
`MotionTable.cs:100-101, 132-139, 152-153, 358-370, 372-379, 395-426`).
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs:394-428`
`adjust_motion` source.
- `docs/research/2026-06-04-animation-sequencer-deep-dive.md` (per
MEMORY.md) — prior deep-dive already exists; ranked 8 divergences
including "missing pending_motions/MotionDone HIGH" — **this current
file does not implement any `pending_motions` concept** (no field named
that, no queueing of *future* motions ahead of the current queue beyond
the single link+cycle model) — worth checking whether that HIGH-ranked
gap from the prior research drop is still open against this current
implementation.

View file

@ -0,0 +1,583 @@
# ACE CSequence port map — cross-reference vs. 2013 retail decomp
Scope: `references/ACE/Source/ACE.Server/Physics/Animation/{Sequence.cs, AnimSequenceNode.cs,
AnimData.cs, AFrame.cs}` + `MotionTable.cs`'s `add_motion/combine_motion/subtract_motion/change_cycle_speed`.
Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (`CSequence::*`,
`AnimSequenceNode::*`, free functions `add_motion`/`combine_motion`/`subtract_motion`/`change_cycle_speed`)
+ verbatim struct defs in `docs/research/named-retail/acclient.h` (line 30747 `CSequence`, line 31063
`AnimSequenceNode`).
## HEADLINE DIVERGENCE (all downstream methods inherit this)
**`Sequence.FrameNumber` is `float` in ACE; retail's `CSequence::frame_number` is `long double`
(80-bit x87 extended precision), not even `double`.**
`acclient.h:30754`: `long double frame_number;`
Every retail arithmetic op on frame_number/timeElapsed in `update_internal`,
`advance_to_next_animation`, `apply_physics` explicitly upcasts to `(long double)` before the
op and downcasts back only where storing into a `float` field (velocity/omega components,
`AnimSequenceNode::framerate`). The comparisons that decide "did we cross a frame boundary" run
at x87-extended precision in retail, at `float` precision in ACE.
Public entry point confirms the wire type: `CSequence::update(class CSequence* this, double arg2, class Frame* arg3)`
@ `0x00525b80` — the *external* `quantum` parameter is `double`, matching `MotionInterp` callers
(ACE's `Sequence.Update(float quantum, ...)` is `float`). Internally retail immediately treats it
as `long double` in the callee.
**Practical effect:** frame-boundary crossing math (`Math.Floor(frameNum) > lastFrame`,
`get_high_frame() < Math.Floor(frameNum)`) can disagree between ACE (float) and retail (80-bit)
at the ULP level near exact frame boundaries — this is a plausible root cause for
subtle frame-swap / off-by-one animation bugs when the accumulated quantum sum lands very close
to an integer frame number. acdream's own port should use `double` at minimum (C# doesn't expose
80-bit extended); pure `float` (ACE's choice) is the most divergent option available.
## `Sequence.cs` (ACE) ↔ `CSequence` (retail) — per-method map
### Fields
| ACE field | Retail field (`acclient.h:30747`) | Type match? |
|---|---|---|
| `int ID` | (not in CSequence; ACE-only bookkeeping, no retail equivalent found in struct) | N/A |
| `LinkedList<AnimSequenceNode> AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode<T>` | `DLList<AnimSequenceNode> anim_list` (intrusive doubly-linked list) + `AnimSequenceNode *first_cyclic` + `AnimSequenceNode *curr_anim` | Structural match; ACE trades retail's intrusive DLList for `System.Collections.Generic.LinkedList<T>`, semantically equivalent but allocates node wrappers separately (see `apricot()` note below) |
| `Vector3 Velocity` | `AC1Legacy::Vector3 velocity` | float×3, match |
| `Vector3 Omega` | `AC1Legacy::Vector3 omega` | float×3, match |
| `PhysicsObj HookObj` | `CPhysicsObj *hook_obj` | match |
| `float FrameNumber` | `long double frame_number` | **DIVERGENT — see headline** |
| `AnimationFrame PlacementFrame` | `AnimFrame *placement_frame` | match |
| `int PlacementFrameID` | `unsigned int placement_frame_id` | match (signedness cosmetic) |
| `bool IsTrivial` | `int bIsTrivial` | match (never read/written elsewhere in ACE's Sequence.cs — dead field there too) |
### Constructor / `Init()`
- Retail `CSequence::CSequence` (`0x005249f0`): `memset(&anim_list, 0, 0x28)` then
`memset(&frame_number, 0, 0x18)` — i.e. zeroes `frame_number`, `curr_anim`, `placement_frame`,
`placement_frame_id`, `bIsTrivial` in one sweep. Velocity/omega/first_cyclic/hook_obj are
zeroed by the first memset (part of `anim_list`+`first_cyclic`+`velocity`+`omega`+`hook_obj`
span, 0x28 bytes).
- ACE `Init()`: sets `Velocity = Zero`, `Omega = Zero`, `FrameNumber = 0.0f`, `AnimList = new()`.
Does **not** reset `CurrAnim`, `FirstCyclic`, `HookObj`, `PlacementFrame`,
`PlacementFrameID`, `IsTrivial` — those are left at C# default (null/0) only because `Init()`
is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge
if `Init()` were ever called again on a live Sequence (retail's memset always re-zeros
everything; ACE's `Init()` does not).
### `Update(quantum, ref offsetFrame)``CSequence::update` (`0x00525b80`)
Exact 1:1 structural match:
```
retail: if (anim_list.head_ != 0) { update_internal(...); apricot(); return; }
else if (arg3 != null) apply_physics(arg3, arg2, arg2);
ACE: if (AnimList.First != null) { update_internal(...); apricot(); }
else if (offsetFrame != null) apply_physics(offsetFrame, quantum, quantum);
```
Param type: retail `arg2` is `double` at this boundary (external quantum). ACE's `quantum` is
`float`. See headline.
### `advance_to_next_animation``CSequence::advance_to_next_animation` (`0x005252b0`)
Retail signature: `(this, double arg2 /*timeElapsed*/, AnimSequenceNode** arg3 /*animNode*/,
double* arg4 /*frameNum*/, Frame* arg5 /*frame*/)`.
Structurally identical two-branch dispatch on `timeElapsed >= 0.0`:
- **Forward branch** (`timeElapsed >= 0`): if `frame != null && currAnim.Framerate < 0` (i.e.
finishing a *reverse-playing* anim), subtract `get_pos_frame(frameNum)`, apply_physics with
`1/framerate` if `|framerate| > EPSILON`. Then advance `animNode` to `.Next` or wrap to
`FirstCyclic`. `frameNum = currAnim.get_starting_frame()`. If `frame != null && framerate > 0`
(starting a *forward-playing* anim), combine pos_frame, apply_physics.
ACE's `advance_to_next_animation` (`Sequence.cs:145`) matches line-for-line, including the
`Math.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILON` guards on `apply_physics`.
- **Backward branch** (`timeElapsed < 0`): mirror image — subtract when `framerate >= 0`
(finishing forward-playing), step to `.Previous` or wrap to `List.Last`, `frameNum =
get_ending_frame()`, combine when `framerate < 0` (starting reverse-playing).
ACE matches.
- EPSILON constant used for `|framerate|` compares: retail literal `0.000199999995f`
`0.0002f` = `ACE.Server.Physics.PhysicsGlobals.EPSILON` (`references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9`). Confirmed identical constant.
### `append_animation``CSequence::append_animation` (`0x00525510`)
Retail: allocate `AnimSequenceNode(arg2)`; if `has_anim()` fails, delete + return (no-op). Else
insert at tail of `anim_list`, set `first_cyclic = tail` (**every** append moves `first_cyclic`
to the newest node — i.e. "cyclic" region is always exactly the last-appended anim until removed).
If `curr_anim == null`: set `curr_anim = head`, `frame_number = get_starting_frame(head)` (or,
in an unreachable dead branch, `get_starting_frame(nullptr)` if head is somehow still null after
just inserting — BinNinja artifact, not real control flow).
ACE (`Sequence.cs:203`) matches: `if (!node.has_anim()) return;` — but ACE does NOT delete the
orphan node explicit (no-op is fine in GC'd C#, matches retail's leak-avoidance intent). `AnimList.AddLast`, `FirstCyclic = AnimList.Last`, `if (CurrAnim == null) { CurrAnim = AnimList.First; FrameNumber = CurrAnim.Value.get_starting_frame(); }`. Faithful.
### `apply_physics(frame, quantum, sign)``CSequence::apply_physics` (`0x00524ab0`)
Retail: `quantum = sign>=0 ? |quantum| : -|quantum|` (sign-copy pattern — note retail's param
order is `(Frame*, double quantum-as-arg3, double sign-as-arg4)`, i.e. **arg3 is the magnitude
source, arg4 is only used for its sign**). Then `Origin += Velocity * quantum` per-axis (retail
does each axis as a separate cast-heavy expression — no semantic difference), `Frame::rotate(Omega
* quantum)`.
ACE (`Sequence.cs:221`): `if (sign>=0.0) quantum=Abs(quantum); else quantum=-Abs(quantum); frame.Origin += Velocity*quantum; frame.Rotate(Omega*quantum);` — exact match. All arithmetic in
retail runs at `long double`; ACE at `float`. Same headline-precision divergence as FrameNumber.
### `apricot()``CSequence::apricot` (`0x00524b40`)
Purpose: after `update_internal` may have advanced `curr_anim` forward past older entries,
prune any anim nodes from `anim_list.head_` up to (but not including) `curr_anim`, UNLESS we hit
`first_cyclic` first (in which case stop — don't prune into the cyclic region).
Retail: `i = head; if (i != curr_anim) { while (i != first_cyclic) { if (i == first_cyclic)
break; delete-unlink(i); i = head; if (i == curr_anim) break; } }` — i.e. loop re-reads `head`
after every unlink (since unlinking changes what head is), and has a **redundant double check**
of `i == first_cyclic` (once as the while-condition, once again as the first statement inside
the loop before the delete — likely because retail's `while` condition is evaluated at the *top*,
and the body immediately re-checks in case the initial `head` already equals `first_cyclic`, which
would only be reachable if `i != curr_anim` was somehow also true — defensive but effectively the
same predicate twice).
ACE (`Sequence.cs:232`):
```csharp
var node = AnimList.First;
while (!node.Equals(CurrAnim)) {
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
```
Semantically equivalent to retail (loop-while-not-curr-anim, break-if-first-cyclic, remove head,
re-read head). Faithful port; the double-check redundancy in retail's disassembly collapses to
ACE's single `if` because C#'s `while(cond)` + `if(cond) break` at the top of the body is exactly
retail's structure once the duplicate compiler artifact is discounted.
### `clear_animations()``CSequence::clear_animations` (`0x00524dc0`)
Retail: pop every node off `anim_list` (unlink+delete each), then `first_cyclic = nullptr`,
`frame_number = 0`, `curr_anim = nullptr`.
ACE: `AnimList.Clear(); FirstCyclic = null; FrameNumber = 0; CurrAnim = null;` — exact match
(GC handles the per-node delete implicitly).
### `clear_physics()``CSequence::clear_physics` (`0x00524d50`)
Retail: zero `velocity` and `omega` component-wise. ACE: `Velocity = Vector3.Zero; Omega =
Vector3.Zero;`. Match.
### `Clear()``CSequence::clear` (`0x005255b0`)
Retail: `clear_animations(); clear_physics();` — does **NOT** touch `placement_frame` /
`placement_frame_id` in the retail disasm shown (only two calls visible at `0x005255b3`/
`0x005255ba`). ACE's `Clear()` (`Sequence.cs:71`) additionally sets `PlacementFrame = null;
PlacementFrameID = 0;` — **this is an ACE-only addition beyond what the two-instruction retail
`clear()` body does.** Worth flagging as a possible ACE embellishment/bug if a future port
strictly mirrors retail's `clear()`; however note ACE's own `~CSequence`/dtor is not modeled at
all (C# has no destructor equivalent needed), so this may be ACE compensating for a different owner-lifecycle assumption. Flag for acdream: **do not blindly copy ACE's `Clear()` — verify whether placement-frame reset belongs here or only in `UnPack`** (retail's `UnPack` explicitly nulls `placement_frame`/`placement_frame_id` — see below).
### `remove_cyclic_anims()``CSequence::remove_cyclic_anims` (`0x00524e40`)
Retail: iterate from `first_cyclic` forward (`AnimSequenceNode::GetNext`); for each node, if
`curr_anim == node`: set `curr_anim = GetPrev(node)`; if that prev is null, `frame_number = 0`,
else `frame_number = get_ending_frame(prev)`. Then unlink+delete `node` regardless. After the
loop, `first_cyclic = anim_list.tail_` (or null if list now empty — implied by the trailing code
not fully captured above but consistent with ACE).
ACE (`Sequence.cs:303`):
```csharp
var node = FirstCyclic;
while (node != null) {
if (CurrAnim.Equals(node)) {
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
```
Faithful — matches retail's per-node dispose-then-advance and the final `first_cyclic = tail`
reset.
### `remove_link_animations(amount)``CSequence::remove_link_animations` (`0x00524be0`)
Retail: loop `amount` times; each iteration, if `GetPrev(first_cyclic) == null` return early
(no more link anims to remove); if `curr_anim == GetPrev(first_cyclic)`, snap `curr_anim =
first_cyclic` and `frame_number = get_starting_frame(first_cyclic)`; then unlink+delete
`GetPrev(first_cyclic)`.
ACE (`Sequence.cs:324`) matches exactly, including the early-return-not-break semantics
(`if (FirstCyclic.Previous == null) return;` inside the `for` loop — matches retail's `break`-out-of-do-while-then-return-since-nothing-else-follows pattern).
### `remove_all_link_animations()``CSequence::remove_all_link_animations` (`0x00524ca0`)
Retail: `while (first_cyclic != null && GetPrev(first_cyclic) != null) { same snap-then-delete
pattern as remove_link_animations, unbounded }`.
ACE (`Sequence.cs:289`): `while (FirstCyclic != null && FirstCyclic.Previous != null) { if
(CurrAnim.Equals(FirstCyclic.Previous)) { CurrAnim = FirstCyclic; if (CurrAnim != null)
FrameNumber = CurrAnim.Value.get_starting_frame(); } AnimList.Remove(FirstCyclic.Previous); }`
Match.
### `execute_hooks(animFrame, dir)``CSequence::execute_hooks` (`0x00524830`)
Retail: `if (hook_obj != 0) for (hook in animFrame->hooks) if (hook.direction_ == 0 /*Both*/ ||
dir == hook.direction_) hook_obj->add_anim_hook(hook)`.
ACE (`Sequence.cs:262`): `if (animFrame == null || HookObj == null) return; foreach (hook in
animFrame.Hooks) if (hook.Direction == Both || hook.Direction == dir) HookObj.add_anim_hook(hook);`
Match (ACE adds an explicit `animFrame == null` guard retail doesn't need because retail always
passes a valid `arg2` from `AnimSequenceNode::get_part_frame` which can itself return null —
retail's caller `update_internal` still calls `execute_hooks` unconditionally with a
possibly-null frame pointer, then retail's `execute_hooks` dereferences `arg2->hooks` **without
a null check** at `0x00524844`. **This is a latent null-deref risk in retail itself** if
`get_part_frame` returns null for an out-of-range frame index — ACE's added `animFrame == null`
guard is a defensive divergence, not a bug, and is the *correct* choice for a managed port. Note
this in case a "PARTSDIAG null-guard" investigation (per project memory) surfaces this exact
retail-side latent null-deref as the root cause of a real bug.)
### `get_curr_frame_number()``CSequence::get_curr_frame_number` (`0x005249d0`)
Retail: `floor(frame_number); return (int)floor_result` (`_ftol2` = truncating float-to-long
after `floor`). ACE: `(int)Math.Floor(FrameNumber)`. Match, modulo the float-vs-long-double
headline divergence.
### `get_curr_animframe()``CSequence::get_curr_animframe` (`0x00524970`)
Retail: `if (curr_anim == null) return placement_frame; return curr_anim->get_part_frame((int)floor(frame_number));`
ACE `GetCurrAnimFrame()` (`Sequence.cs:89`): `if (CurrAnim == null) return PlacementFrame; return
CurrAnim.Value.get_part_frame(get_curr_frame_number());` where `get_curr_frame_number()` is the
same floor+truncate. Match.
### `set_placement_frame` / `set_velocity` / `set_omega` / `combine_physics` / `subtract_physics`
All four are trivial field setters/accumulators in both retail and ACE — verified byte-for-byte
equivalent logic (`Sequence.cs:111-130`, `:83-87`, `:345-349`). No divergence.
### `multiply_cyclic_animation_framerate(rate)``CSequence::multiply_cyclic_animation_fr` (`0x00524940`)
Retail: `for (n = first_cyclic; n != null; n = GetNext(n)) AnimSequenceNode::multiply_framerate(n, rate);`
ACE (`Sequence.cs:277`): identical loop over `FirstCyclic`. Match.
### `update_internal(timeElapsed, ref animNode, ref frameNum, ref frame)``CSequence::update_internal` (`0x005255d0`)
This is the core per-tick state machine; retail's decompiled x87 soup (heavy `long double`
comparison-flag reconstruction, unresolved `fld`/`fcomp` mnemonics the decompiler couldn't
symbolize) obscures direct reading, but the **control-flow skeleton is fully recoverable** and
matches ACE 1:1:
```
lastFrame = floor(frameNum)
frametime = framerate * timeElapsed
frameNum += frametime
frameTimeElapsed = 0
animDone = false
if (frametime > 0):
if (get_high_frame() < floor(frameNum)):
frameOffset = frameNum - get_high_frame() - 1; clamp to >=0
if |framerate| > EPS: frameTimeElapsed = frameOffset / framerate
frameNum = get_high_frame()
animDone = true
while floor(frameNum) > lastFrame:
if frame != null:
combine pos_frame(lastFrame) into frame (if pos_frames != null)
if |framerate| > EPS: apply_physics(frame, 1/framerate, timeElapsed)
execute_hooks(get_part_frame(lastFrame), Forward)
lastFrame++
elif (frametime < 0):
[mirror: get_low_frame(), subtract instead of combine, Backward hooks, lastFrame--]
else:
if frame != null && |timeElapsed| > EPS: apply_physics(frame, timeElapsed, timeElapsed)
if (!animDone): return
if (hook_obj != null && head != first_cyclic): hook_obj->add_anim_hook(AnimDoneHook)
advance_to_next_animation(timeElapsed, ref animNode, ref frameNum, ref frame)
timeElapsed = frameTimeElapsed
[LOOP back to top] <-- retail implements this as an actual `while(true)` loop with the
call to advance_to_next_animation + timeElapsed reassignment,
THEN re-enters the top of the function body (0x005255e8 label).
```
ACE (`Sequence.cs:351`) implements the exact same skeleton but as **explicit tail recursion**
(`update_internal(timeElapsed, ref animNode, ref frameNum, ref frame);` as the last statement)
rather than retail's `while(true)` loop. Semantically equivalent (C#'s JIT does NOT guarantee
tail-call optimization for this shape, so very long same-tick multi-anim-boundary crossings
could theoretically risk stack depth in ACE where retail would not — low practical risk since
`frameTimeElapsed` shrinks each iteration and hits `frametime == 0` quickly, but note this as
a structural (not behavioral) implementation difference).
Retail's `execute_hooks` direction constant: forward pass at `0x0052590c` passes `0xffffffff`
(-1, all-bits) not `1`; backward pass at `0x0052578c` passes `1`. **This looks inverted from a
naive reading** (forward should intuitively be "Forward" not `-1`), but cross-check against
`AnimationHookDir` enum values: ACE's port passes `AnimationHookDir.Forward` for the `lastFrame++`
(ascending, `frametime>0`) branch and `AnimationHookDir.Backward` for the `lastFrame--`
(descending, `frametime<0`) branch — i.e. ACE's C# reads correctly against the *semantic* forward/
backward regardless of retail's raw enum encoding. Need to verify `AnimationHookDir` enum's
actual underlying values in `ACE.Entity.Enum` to confirm `Forward == -1`/`0xffffffff` vs `1`,
but this is very likely just how the enum is defined (Both=0, Forward=-1, Backward=1, or similar
non-sequential encoding) rather than an ACE bug — **flag as needing confirmation, not a
confirmed divergence.**
EPSILON compares throughout use the same `0.000199999995f` literal as elsewhere. The `frametime
== 0` branch's guard is `|timeElapsed| > EPSILON` before calling `apply_physics(frame,
timeElapsed, timeElapsed)` — ACE matches exactly (`Sequence.cs:424`).
### `UnPack` (retail-only relevance)
`CSequence::UnPack` (`0x005259d0`) explicitly does `clear_animations(); clear_physics();
placement_frame = null; placement_frame_id = 0;` before deserializing — this is where retail
actually nulls the placement frame, which is the retail-verified justification for ACE's
`Clear()` including that reset (even though the disassembled 2-line `clear()` body itself does
not). ACE has no `Sequence.UnPack` in this file (no wire (de)serialization path ported) — this
is out of scope for acdream's runtime port (server-authoritative motion state, not client dat
deserialization) but is why ACE's `Clear()` and `clear()`/`clear_animations()`+`clear_physics()`
appear to disagree — they're actually modeling two different retail call sites (`clear()` proper
vs. `UnPack`'s manual clear+reset sequence). **Not a bug in ACE; a naming/scope conflation worth
noting for anyone tracing ACE's `Clear()` back to a single retail function.**
## `AnimSequenceNode.cs` (ACE) ↔ `AnimSequenceNode` (retail struct @ `acclient.h:31063`)
### Fields
`CAnimation *anim` / `float framerate` / `int low_frame` / `int high_frame` — all match ACE's
`Animation Anim` / `float Framerate` / `int LowFrame` / `int HighFrame` exactly, including types
(both `int`, not `uint`).
### Default ctor `AnimSequenceNode()` ↔ retail `AnimSequenceNode::AnimSequenceNode()` (`0x00525d30`)
Retail: `framerate = 30f; low_frame = 0xffffffff (-1); high_frame = 0xffffffff (-1);` (both
low+high default to **-1**, not `low=0`).
ACE (`AnimSequenceNode.cs:15`): `Framerate = 30.0f; LowFrame = 0; HighFrame = -1;`
**CONFIRMED DIVERGENCE: ACE's parameterless ctor sets `LowFrame = 0` where retail sets
`low_frame = -1`.** This constructor is never actually invoked by ACE's own runtime path (the
only call site is `new AnimSequenceNode(animData)`, the parameterized overload, which explicitly
sets `LowFrame = animData.LowFrame`), so this divergence is currently dormant/unreachable in
ACE's code — but it is a real textual mismatch against retail's default-construct semantics and
would matter if any future code path constructs a bare `AnimSequenceNode()` and relies on default
`LowFrame`.
### `get_starting_frame()``AnimSequenceNode::get_starting_frame` (`0x00525c80`)
Retail: `if (framerate < 0) return high_frame + 1; else return low_frame;` — **returns a plain
`int32_t`**, i.e. `high_frame + 1` with NO epsilon subtraction.
ACE (`AnimSequenceNode.cs:72`):
```csharp
public float get_starting_frame() {
if (Framerate >= 0.0f) return LowFrame;
else return HighFrame + 1 - PhysicsGlobals.EPSILON;
}
```
**CONFIRMED DIVERGENCE (significant):** ACE subtracts `PhysicsGlobals.EPSILON` (0.0002) from
`HighFrame + 1` when framerate is negative — retail does **not**; retail returns the exact
integer `high_frame + 1`. Also note the boundary condition flip: retail's branch condition is
`framerate < 0` (strict) with the `>= 0` case falling to `low_frame`; ACE's condition is
`Framerate >= 0.0f` returning `LowFrame` (equivalent boundary, `framerate==0` behaves the same
in both — returns `low_frame`/`LowFrame`). The boundary logic itself is faithful; **only the
epsilon subtraction is a fabricated addition not present in retail.** This likely exists in ACE
to avoid `Math.Floor` landing exactly on `HighFrame+1` and reading one frame past the end when
used as a float frame-cursor, but retail doesn't need it because retail's `get_starting_frame`
return value is immediately truncated back to an `int` in most call sites — however note retail's
`CSequence::advance_to_next_animation` and `remove_cyclic_anims` etc. store this int return value
directly into `frame_number` (a `long double`), so no fractional epsilon ever appears in retail's
frame_number for this codepath. **acdream should NOT copy this epsilon subtraction if porting
`get_starting_frame` faithfully** — investigate whether ACE added it to work around a downstream
float-precision issue introduced by ACE's OWN `float FrameNumber` choice (i.e. a workaround for
ACE's own divergence, compounding on top of it) rather than something retail does.
### `get_ending_frame()``AnimSequenceNode::get_ending_frame` (`0x00525cb0`)
Retail: `if (framerate >= 0) return high_frame + 1; else return low_frame;` — again plain
integer, no epsilon.
ACE (`AnimSequenceNode.cs:31`):
```csharp
public float get_ending_frame() {
if (Framerate >= 0.0f) return HighFrame + 1 - PhysicsGlobals.EPSILON;
else return LowFrame;
}
```
**Same confirmed divergence as `get_starting_frame`** — ACE subtracts EPSILON from `HighFrame+1`
where retail returns the bare int. Branch condition (`>= 0` → high+1 path) matches retail exactly
this time (mirrors correctly — `get_starting_frame` and `get_ending_frame` are exact opposites of
each other by design, both in retail and ACE); only the epsilon fabrication persists.
### `get_high_frame()` / `get_low_frame()`
Trivial accessors in both — direct field reads. No retail decompiled body found by name (likely
inlined/not separately emitted, or address not matched by this grep pass), but ACE's are pure
passthroughs (`return HighFrame;` / `return LowFrame;`) which cannot diverge from the struct field
values already confirmed to match. No risk.
### `get_part_frame(frameIdx)``AnimSequenceNode::get_part_frame` (`0x00525c40`)
Retail: `if (anim != null && arg2 >= 0 && arg2 < anim->num_frames) return &anim->part_frames[arg2];
else return null;`
ACE (`AnimSequenceNode.cs:49`): `if (Anim == null) return null; if (frameIdx < 0 || frameIdx >=
Anim.NumFrames) return null; return Anim.PartFrames[frameIdx];` Logically equivalent (De Morgan's
of the same guard). Match. **Note the retail-side latent null-deref risk flagged above in
`execute_hooks`**: retail's `get_part_frame` DOES null-check bounds here, so a null `AnimFrame*`
can legitimately flow into `execute_hooks(this, get_part_frame(...), dir)` when `frameIdx` is
out of `[0, num_frames)` — retail's `execute_hooks` then dereferences it unconditionally. ACE
avoids this crash class entirely via its own `animFrame == null` guard in `execute_hooks`.
### `get_pos_frame(int frameIdx)``AnimSequenceNode::get_pos_frame(int32_t)` (`0x00525c10`)
Retail: same null/bounds guard as `get_part_frame` but against `PosFrames`/`pos_frames`, returns
`&anim->pos_frames[arg2 * 0x1c]` (0x1c = sizeof(AFrame) = 28 bytes: Vector3 origin (12) +
Quaternion (16) = 28 — confirms `AFrame` layout) on success, else... retail returns `0` (null
pointer) on failure, whereas **ACE returns `new AFrame()`** (identity frame) instead of null:
```csharp
public AFrame get_pos_frame(int frameIdx) {
if (Anim == null) return new AFrame();
if (frameIdx < 0 || frameIdx >= Anim.PosFrames.Count) return new AFrame();
return Anim.PosFrames[frameIdx];
}
```
**CONFIRMED DIVERGENCE:** retail can return a null `AFrame*` from `get_pos_frame`; ACE always
returns a non-null identity `AFrame`. This is almost certainly intentional/necessary in ACE
because C#'s callers (`update_internal`, `advance_to_next_animation`) call
`AFrame.Combine(frame, currAnim.get_pos_frame(...))` / `frame.Subtract(...)` unconditionally when
`currAnim.Anim.PosFrames.Count > 0` is already true (guarding the call site) — so in practice the
only way retail's null path is hit is if `pos_frames` is non-null overall but the specific index
is out of the current `[0, num_frames)` bounds, an edge retail's callers appear to avoid by
construction. ACE's identity-frame fallback is a defensive substitute for retail's null (which
would otherwise NPE `AFrame.Combine`/`Subtract` in C#) — functionally converges to a no-op combine
in the one path where it could differ, matching retail's *intended* behavior (no-op) via a
different mechanism (identity frame vs. skipped call). Low risk, but textually a real divergence
worth listing.
There's also a `float`-overload convenience wrapper `get_pos_frame(float frameIdx)` in ACE
(`:67`, `=> get_pos_frame((int)Math.Floor(frameIdx))`) with no direct 1:1 retail counterpart found
by this pass — likely inlined at each call site in retail rather than a separate overload; no
behavioral risk since it's a pure convenience delegator.
### `has_anim(int appraisalProfile = 0)``AnimSequenceNode::has_anim` (`0x00525c70`)
Retail: `return anim != 0;` (no parameter). ACE: `return Anim != null;` with a vestigial unused
`appraisalProfile` parameter (default 0, never read in the body) — **ACE-only dead parameter**,
harmless (matches retail's actual logic; the extra param appears to be scaffolding for a
different unrelated retail overload elsewhere, not a behavioral difference here).
### `multiply_framerate(multiplier)``AnimSequenceNode::multiply_framerate` (`0x00525be0`)
Retail: `if (multiplier < 0) swap(low_frame, high_frame); framerate *= multiplier;`
ACE (`:85`): `if (multiplier < 0.0f) { swap LowFrame/HighFrame } Framerate *= multiplier;` Exact
match, including the swap-BEFORE-multiply ordering (doesn't matter for correctness here since the
swap doesn't depend on the post-multiply framerate value, but confirms ACE preserved retail's
instruction order faithfully).
### `set_animation_id(animID)``AnimSequenceNode::set_animation_id` (`0x00525d60`, body continues past what this pass read in full — only header + first 3 lines captured)
ACE (`:96`): looks up `Anim = new Animation(DBObj.GetAnimation(animID))`; if `Anim == null`
return; clamps `HighFrame` to `-1 -> NumFrames-1` if still default, clamps `LowFrame`/`HighFrame`
individually if `>= NumFrames`, and clamps `LowFrame > HighFrame` by raising `HighFrame =
LowFrame`. This clamping logic was not fully re-derived from the retail disasm in this pass
(truncated read) — **recommend a follow-up grep of `AnimSequenceNode::set_animation_id` body
past `0x00525d60` before treating ACE's clamp order as verified**; flagged as unverified rather
than confirmed-matching.
### Parameterized ctor `AnimSequenceNode(AnimData animData)` ↔ retail `AnimSequenceNode::AnimSequenceNode(AnimData const*)` (`0x00525f90`, referenced at `0x00525f80` calling `set_animation_id`)
ACE (`:22`): `Framerate = animData.Framerate; LowFrame = animData.LowFrame; HighFrame =
animData.HighFrame; set_animation_id(animData.AnimID);` — order (set framerate/low/high fields
FIRST, then resolve+clamp via `set_animation_id`) matches the retail call sequence implied by
`0x00525f80` calling `set_animation_id` from within the ctor body (consistent with fields being
pre-populated by the ctor's other init statements before the call, standard C++ member-init-list
ordering). Considered faithful pending the same `set_animation_id` body caveat above.
## `AnimData.cs` (ACE) ↔ retail `AnimData`/`operator*`
Retail default ctor `AnimData::AnimData` (`0x00525ce0`): `anim_id.id = 0; low_frame = 0; high_frame
= 0xffffffff (-1); framerate = 30f;`
ACE `AnimData` (this file, `references/ACE/.../Animation/AnimData.cs`) is just a plain data holder
with a parameterized ctor `AnimData(DatLoader.Entity.AnimData animData, float speed = 1.0f)` that
does `AnimID = animData.AnimId; LowFrame = animData.LowFrame; HighFrame = animData.HighFrame;
Framerate = animData.Framerate * speed;` — this matches retail's `operator*(float speed, AnimData
const* src)` (`0x00525d00`, invoked from `add_motion` at `0x0052255b`):
```
retail: dst.id = src.id; dst.low_frame = src.low_frame; dst.high_frame = src.high_frame;
dst.framerate = src.framerate * speed;
```
Field-for-field, operation-for-operation match, including that `Framerate` is the ONLY field
scaled by `speed` (low/high frame bounds pass through unscaled). No parameterless-ctor
default-value divergence to flag since ACE's `AnimData()` here is a no-op empty ctor (all fields
default to C# zero values: `0, 0, 0, 0f`) — **diverges from retail's `low_frame=0,
high_frame=0xffffffff, framerate=30f` defaults**, but this parameterless ctor does not appear to
be invoked anywhere in the `add_motion` call chain (only the parameterized ctor is used at the
`MotionTable.add_motion` call site), so — like `AnimSequenceNode()`'s bare ctor — this is a
dormant/unreachable-in-practice divergence, not an active bug.
## `AFrame.cs` — spot notes (not the primary ask, but touched by Sequence/AnimSequenceNode)
`AFrame` is ACE's C# port of retail's `Frame`/`AFrame` (28-byte struct = Vector3 origin (12B) +
Quaternion orientation (16B), confirmed via the `0x1c` (28) stride multiplier in
`AnimSequenceNode::get_pos_frame` at `0x00525c2c`). `Combine`/`Subtract`/`Rotate`/`apply_physics`
call sites all operate on this type consistently between ACE and retail's `Frame::combine`,
`Frame::subtract1`, `Frame::rotate` (referenced by name at `0x0052541b`, `0x005254c2`,
`0x00524b2d` etc. — not independently re-derived body-for-body in this pass; flagged out of scope
per the task's method list, but the call-site shapes into/out of `Sequence`/`AnimSequenceNode`
were confirmed consistent).
## `MotionTable.cs` (ACE, class name `MotionTable`) ↔ retail `CMotionTable` — the 4 requested methods
Retail's class is named `CMotionTable` (not `MotionTable`) — ACE renamed it during the port. The
4 target methods are **retail FREE FUNCTIONS** (not `CMotionTable::` member functions) that take
a `CSequence*` as their first parameter — ACE ported them as instance methods on `MotionTable`
taking a `Sequence` parameter, a structural (not behavioral) reshaping.
### `add_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `add_motion` (`0x005224b0`)
Retail: `if (motionData == null) return; set_velocity(motionData.velocity * speed); set_omega(
motionData.omega * speed); for each anim in motionData.anims: append_animation(AnimData(speed *
anim))` — i.e. append_animation is called with a **freshly speed-scaled `AnimData` value**
(via `operator*`), never the raw `motionData.Anims[i]`.
ACE (`MotionTable.cs:358`):
```csharp
if (motionData == null) return;
sequence.SetVelocity(motionData.Velocity * speed);
sequence.SetOmega(motionData.Omega * speed);
for (i in motionData.Anims.Count) {
var animData = new AnimData(motionData.Anims[i], speed);
sequence.append_animation(animData);
}
```
Exact match, including the crucial detail that velocity/omega REPLACE (via `set_velocity`/
`SetVelocity`, not accumulate) the sequence's existing physics vector, unlike `combine_motion`/
`subtract_motion` below.
### `combine_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `combine_motion` (`0x00522580`)
Retail: `if (motionData == null) return; combine_physics(velocity*speed, omega*speed);` (ADDS
into existing sequence velocity/omega via `CSequence::combine_physics`).
ACE (`MotionTable.cs:381`): `if (motionData == null) return; sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `subtract_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `subtract_motion` (`0x00522600`)
Retail: `if (motionData == null) return; subtract_physics(velocity*speed, omega*speed);`
ACE (`MotionTable.cs:388`): `if (motionData == null) return; sequence.subtract_physics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `change_cycle_speed(Sequence sequence, MotionData motionData, float substateMod, float speedMod)` ↔ free fn `change_cycle_speed` (`0x00522290`)
Retail: `if (|substateMod| > EPSILON) multiply_cyclic_animation_fr(speedMod / substateMod); else
if (|speedMod| < EPSILON) multiply_cyclic_animation_fr(0);` **note the retail param order is
`(CSequence*, MotionData* [UNUSED in this function's body], float substateMod, float speedMod)`
`motionData` is passed but never dereferenced inside `change_cycle_speed` itself** (it's there
for signature consistency with the other 3 sibling functions / call-site uniformity, not because
the function needs it).
ACE (`MotionTable.cs:372`):
```csharp
if (Math.Abs(substateMod) > PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(speedMod / substateMod);
else if (Math.Abs(speedMod) < PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(0);
```
Exact match, including that ACE's `motionData` parameter is likewise unused in the method body
(faithfully preserved as dead/unused, mirroring retail's own unused parameter — not an ACE bug,
an intentional signature-parity choice already present in retail).
**Boundary-condition note:** if `substateMod` is exactly `EPSILON` or between `EPSILON` and some
tiny nonzero value such that neither branch's condition is strictly satisfied (i.e.
`|substateMod| <= EPSILON` AND `|speedMod| >= EPSILON`), **neither branch fires and the cyclic
framerate is left unchanged** in both retail and ACE — this is retail's actual (if slightly odd)
behavior, faithfully reproduced, not a port bug.
## Call-site context confirmed (not itself divergence-graded, informational)
`MotionTable.GetObjectSequence` (ACE) corresponds to retail's `CMotionTable::GetObjectSequence`
(referenced at `0x00522347` from `CMotionTable::re_modify`, and the `sequence.clear_physics();
sequence.remove_cyclic_anims();` pairing before each `add_motion` burst matches retail's
`CSequence::clear_physics(arg4); CSequence::remove_cyclic_anims(arg4);` pattern visible at
`0x005229cf`/`0x005229d8`, `0x00522be3`/`0x00522bec`, `0x00522d6d`/`0x00522d74`,
`0x00522e5d`/`0x00522e64` — four separate call sites in retail's `GetObjectSequence`, matching
ACE's four corresponding branches (`Style` / `SubState` / `Action` / the default-state reset in
`SetDefaultState` which additionally calls `clear_animations()` instead of `remove_cyclic_anims()`
— confirmed intentional, `SetDefaultState` is a full reset not an in-place cycle swap).
`re_modify` in retail (`0x005222e0`, `CMotionTable::re_modify`) reapplies queued modifiers by
popping `MotionState.modifier_head` and re-calling `GetObjectSequence` — this exists in ACE too
(referenced in `MotionTable.cs` but not in the requested method list; noted only for completeness
of the call graph around the 4 target functions).
## Summary of confirmed divergences (ranked by likely runtime impact)
1. **`FrameNumber`/`frame_number`: `float` (ACE) vs `long double`/80-bit-extended (retail).**
Pervasive — affects every frame-boundary comparison in `update_internal`,
`advance_to_next_animation`, `apply_physics`. Highest-impact, hardest to fix in C# (no native
80-bit float type; `double` is the closest available and still not bit-exact to retail).
2. **`AnimSequenceNode.get_starting_frame()` / `get_ending_frame()` subtract
`PhysicsGlobals.EPSILON` from `HighFrame + 1` in ACE; retail returns the bare integer with NO
epsilon.** Directly affects where a reverse-playing animation's start/end frame lands —
potential off-by-epsilon frame read at cycle boundaries. Medium-high impact, easy to fix (just
drop the `- PhysicsGlobals.EPSILON` term) if porting fresh.
3. **`AnimSequenceNode()` parameterless ctor: `LowFrame=0` (ACE) vs `low_frame=-1` (retail).**
Dormant in ACE's current call graph (only the parameterized ctor is actually invoked), but a
real textual mismatch. Low impact unless something starts calling the bare ctor.
4. **`AnimData()` parameterless ctor: all-zero defaults (ACE) vs `low_frame=0, high_frame=-1,
framerate=30f` (retail).** Same dormant-but-real-mismatch profile as #3.
5. **`AnimSequenceNode.get_pos_frame` returns identity `AFrame` on failure (ACE) vs `null`
pointer (retail).** Functionally converges to a no-op in practice given how call sites guard
invocation; textual divergence only.
6. **ACE's `Clear()` additionally nulls `PlacementFrame`/`PlacementFrameID`, which retail's own
2-instruction `clear()` body does NOT do** — that reset actually belongs to retail's separate
`UnPack` function. Scope conflation, not a behavioral bug, but worth knowing which retail
function ACE's `Clear()` is really modeling.
7. **`update_internal`: retail loops (`while(true)`), ACE recurses (tail call).** Structural only;
equivalent output, theoretical (very unlikely in practice) stack-depth difference in
pathological same-tick multi-boundary-crossing cases.
8. Retail's `execute_hooks` has a latent null-deref if `get_part_frame` returns null for an
out-of-range frame index (no null check before `arg2->hooks`); ACE's `animFrame == null` guard
avoids this crash class — a safe defensive divergence, not something to "fix" toward retail.
9. `AnimSequenceNode::set_animation_id` clamp-order in ACE was NOT independently re-verified
against the full retail body in this pass (only the call header + first lines were read) —
flag for a follow-up targeted grep before treating ACE's clamping as ground truth.
## Constants confirmed identical between ACE and retail
- `EPSILON = 0.0002f` (retail literal `0.000199999995f`, ACE `PhysicsGlobals.EPSILON`) — used
identically in `advance_to_next_animation`, `update_internal`, `apply_physics` guards, and
`change_cycle_speed`.
- `AFrame`/`Frame` struct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed via
`AnimSequenceNode::get_pos_frame`'s `arg2 * 0x1c` index stride.
- `AnimSequenceNode` struct layout: `CAnimation* anim; float framerate; int low_frame; int
high_frame;` — exact field-for-field match with ACE's C# class (types included).
- `CSequence` struct layout (`acclient.h:30747`): confirms `frame_number` is genuinely `long
double`, not a decompiler artifact — this is the verbatim retail header, authoritative.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
# R1 gap map — retail CSequence vs acdream AnimationSequencer
Inputs: `r1-csequence-decomp.md` (verbatim retail extraction, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`), `r1-ace-sequence.md`
(ACE port cross-reference), `r1-acdream-sequencer.md` (current-code map).
Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (R1 stage).
Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) +
`AnimationCommandRouter.cs` / `AnimationHookRouter.cs` / `IAnimationHookSink.cs`.
**Conflicts between the two research passes RESOLVED in this synthesis (re-read raw decomp):**
- **Hook direction constants** — r1-ace-sequence.md's claim ("forward pass at 0x0052590c passes
0xffffffff") was a branch misattribution. Raw decomp verified this pass:
`0x0052578c` `execute_hooks(..., ebx_2, 1)` with `ebx_2 += 1` after = FORWARD crossing → **+1**;
`0x0052590c` `execute_hooks(..., ebx_1, 0xffffffff)` with `ebx_1 -= 1` after = REVERSE → **-1**.
Retail encoding == ACE `AnimationHookDir` (`references/ACE/Source/ACE.Entity/Enum/AnimationHookDir.cs`:
Backward=-1, Both=0, Forward=1) == the DatReaderWriter enum acdream already uses. No enum-remap needed.
- **update_internal fine structure** — r1-csequence-decomp.md §21's cleaned flow is garbled in two
places (an "early return" where retail clamps+continues; `hit_boundary=true` placed
unconditionally after the per-frame loop). The authoritative skeleton is ACE
`Sequence.cs:351-443` (verified verbatim this pass, quoted below in P4), which matches the raw
decomp's branch layout at `0x005255d0`-`0x005259ca`: overshoot check → clamp `frameNum` to
`get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate
(`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop.
- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to
zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries
`timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could
never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far
more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1
pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting
invocations per `CSequence::update` call under induced multi-node overshoot.
Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless;
**HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later
stage; **LOW** = dormant/textual.
---
## 1. ITEMIZED GAP LIST
| # | Retail behavior acdream lacks/diverges on | Decomp anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| G1 | **Direction-aware boundary pair with bare-int values.** Retail `get_starting_frame` = `framerate<0 ? high+1 : low`, `get_ending_frame` = `framerate<0 ? low : high+1` — plain ints, **NO epsilon**. acdream (following ACE) returns `(EndFrame+1) - FrameEpsilon` with `FrameEpsilon = 1e-5` (ACE uses 0.0002); the epsilon is an ACE fabrication compensating for ACE's own `float FrameNumber`. | `0x00525c80`/`0x00525cb0`, pseudo-C lines 302483/302501 (r1-csequence-decomp §26/27); ACE divergence #2 (r1-ace-sequence) | `AnimationSequencer.cs:154,164` (`GetStartFramePosition`/`GetEndFramePosition`), `:171` (`FrameEpsilon=1e-5`) | **BLOCKER** — every boundary decision keys off these values |
| G2 | **`multiply_framerate` swaps `low_frame``high_frame` on negative factor.** acdream keeps `StartFrame ≤ EndFrame` invariant and encodes direction only in Framerate sign, compensating in `Advance` — explicitly documented divergence. Verbatim port requires the swap + the direction-aware G1 pair; the two mechanisms are coupled. | `0x00525be0`, line 302425 (§14a) | `AnimationSequencer.cs` `AnimNode.MultiplyFramerate` (r1-acdream map row "multiply_framerate") | **HIGH** — reverse playback (WalkBackward links, reverse stops) correctness |
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20``Hook_AnimDone 0x0050fda0``CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks``_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 |
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null``apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete |
| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct |
| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms |
| G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) |
| G13 | **`multiply_cyclic_animation_fr` must touch ONLY node framerates.** Retail walks `first_cyclic`→tail calling `multiply_framerate`. acdream's `MultiplyCyclicFramerate` additionally scales `CurrentVelocity/CurrentOmega` — algebraically equivalent to retail's `change_cycle_speed`+`subtract/combine_motion` composite (an R2 mechanism folded in). Core port must separate them or R2's verbatim fast path double-applies. | `0x00524940` (line 300846, §14) | `AnimationSequencer.cs` `MultiplyCyclicFramerate` (r1-acdream map row) | MED (correct today by accident; wrong the moment R2 lands) |
| G14 | **Placement frames absent.** Retail `set_placement_frame`/`placement_frame_id` + `get_curr_animframe` returning `placement_frame` when `curr_anim == null` (static pose for object with no active anims). acdream has no placement concept (identity frames only). Explicit R1 scope item in the plan. | `0x005249b0`/`0x00524970` (lines 300872/300855, §15/16) | no equivalent (grep "placement" hits only a doc comment at `AnimationSequencer.cs:979`) | MED |
| G15 | **`frame_number` precision.** Retail: x87 `long double` (80-bit; verbatim `acclient.h:30747`). acdream: `double` (`AnimationSequencer.cs:303`) — the best C# can do; ACE's `float` is worse. Residual double-vs-extended ULP divergence at exact frame boundaries is an unavoidable adaptation → **needs a divergence-register row in the R1 core commit**. | `acclient.h:30747`; headline of r1-ace-sequence | `AnimationSequencer.cs:302-303` | LOW runtime / **process-MANDATORY** register row |
| G16 | **Node ctor defaults + `set_animation_id` clamp order.** Retail defaults: `framerate=30f, low_frame=-1, high_frame=-1`; `AnimData` defaults `low=0, high=-1, framerate=30f`; clamp order: `high<0→num-1`, `low>=num→num-1`, `high>=num→num-1`, `low>high→high=low`. acdream `LoadAnimNode` handles the `-1` sentinel + `low>high` but not the full order; per-node it also stores `IsLooping/HasPosFrames/Velocity/Omega` fields retail nodes don't have (retail velocity/omega live on the SEQUENCE only). NOTE: r1-ace-sequence flagged set_animation_id as unverified, but r1-csequence-decomp §25 captured the FULL body — resolved, no follow-up grep needed. | `0x00525d30`/`0x00525f90`/`0x00525d60` (lines 302547/302744/302561, §25); `AnimData` ctor `0x00525ce0` | `AnimationSequencer.cs` `AnimNode` + `LoadAnimNode` (r1-acdream map §0, row "Placement / root frames") | MED |
| G17 | **`add_motion` velocity semantics: unconditional REPLACE.** Retail free fn `add_motion` (0x005224b0) calls `set_velocity(motionData.velocity*speed)`/`set_omega(...)` **unconditionally** (a MotionData without the dat HasVelocity bit carries zero → replace-with-zero). acdream gates on `Flags.HasFlag(HasVelocity)` and otherwise LEAVES the previous value (`AnimationSequencer.cs:1288-1294` — comment claims "matches retail's conditional behavior", which the decomp contradicts). Retail avoids the zero-a-running-cycle problem via call-graph (modifiers go through `combine_motion`, not `add_motion`) — an R2 distinction acdream compensates for with this flag gate. | `0x005224b0` (r1-ace-sequence `add_motion` section); `combine_motion 0x00522580` | `AnimationSequencer.cs:1288-1294` | MED — port unconditional replace in the R1 core; keep the gate in the adapter until R2 routes modifiers through combine_motion, then delete (register row if the adapter gate outlives R1) |
| G18 | **`get_pos_frame` returns null out-of-range** (retail), not identity — and retail's `execute_hooks` has a latent null-deref on `arg2->hooks` (no null check). Port: null-return + the ACE-style null guard in execute_hooks as a documented safe divergence (crash-parity with retail is not a goal). acdream's ExecuteHooks already bounds-checks (`:1373`) — keep the guard, cite it. | `0x00525c10`/`0x00524830` (§28/§18); ACE divergences #5/#8 | `AnimationSequencer.cs:1373` | LOW |
| G19 | **`update` entry contract.** Retail `update(double quantum, Frame*)`: non-empty → `update_internal` then **`apricot`**; empty → physics-only. acdream `Advance(float dt)` returns blended `PartTransform[]` and never trims consumed nodes structurally (rebuilds hide it). Core port needs the retail entry + apricot; the blended-frame render output stays an adapter/render-side concern (retail renders off `get_curr_animframe`'s FLOORED index; interpolation lives in CPartArray-land, out of R1 core scope). | `0x00525b80` + apricot `0x00524b40` (§20/22) | `AnimationSequencer.cs:872+` (`Advance`), `BuildBlendedFrame` | **HIGH** (apricot + entry contract); blend seam MED |
| G20 | **`clear()` scope.** Retail `clear` = `clear_animations()+clear_physics()` ONLY (2 calls, 0x005255b0); the placement_frame reset belongs to `UnPack` (0x005259d0). Do not copy ACE's `Clear()` which folds the placement reset in. | line 301828 (§3); ACE divergence #6 | acdream `Reset()` (no external callers) | LOW |
Invented behaviors NOT in the gap list because they are R2/R3 scope and survive R1 **in the
adapter, unchanged**: K-fix18 `skipTransitionLink` (retire in R3 jump family), Fix B
locomotion link-skip (retail mechanism = `remove_redundant_links 0x0051bf20`, R2), stop-anim
fallback + GetLink reversed branch (R2 `get_link`/`GetObjectSequence`), velocity-synthesis
constants Walk=3.12/Run=4.0/Side=1.25 (R3 `get_state_velocity`), `HasCycle` probe (R2),
retail slerp + BuildBlendedFrame (render-side, not CSequence). Each keeps/gets its
divergence-register row when touched.
---
## 2. KEEP LIST — already matching retail
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `execute_hooks` direction filter `dir==0(Both) \|\| dir==caller` | `0x00524830` line 300780; constants verified: fwd=+1 @0x0052578c, rev=-1 @0x0052590c | `AnimationSequencer.cs:1371-1388`; DatReaderWriter `AnimationHookDir` Backward=-1/Both=0/Forward=1 == retail encoding |
| Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) |
| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) |
| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` |
| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") |
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) |
| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) |
| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") |
| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |
| `HighFrame == -1` sentinel → last frame; `low > high → high = low` degenerate guard | `set_animation_id 0x00525d60` clamps | `LoadAnimNode` (r1-acdream map row "Placement / root frames") — partial (see G16 for full order) |
| Fast-path re-speed without restart on same motion (concept) | ACE `MotionTable.cs:132-139`; retail `change_cycle_speed 0x00522290` | `SetCycle` early-return → `MultiplyCyclicFramerate` (G13 caveat) |
| `frame_number` at `double` ≥ ACE's float | `acclient.h:30747` (`long double`) | `AnimationSequencer.cs:303` — already the best-available C# type |
| Retail slerp incl. validation-fallback quirk (render blend, not CSequence) | `FUN_005360d0` (chunk_00530000.c:4799-4846) | `SlerpRetailClient` — keep untouched |
---
## 3. PORT ORDER — R1 commit sequence (tests-first, each one commit)
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retail names
(`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after.
Every commit: build+test green; register rows added/retired in-commit.
**P0 — pseudocode + ambiguity pinning (docs only).**
Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md,
CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity:
leftover-time carry vs `arg2=0` at `0x0052598a`.
Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` +
`acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1
advance-per-update under a stall/lag proves the carry. Also capture
`append_animation`/`remove_cyclic_anims` arg logs here (they feed P2/P5 goldens — one cdb session
serves all of R1).
Dependencies: none. This is the workflow's mandatory step-3 artifact.
**P1 — `AnimSequenceNode` verbatim.** (closes G1, G2, G16, G18-node-half)
Fields `anim/framerate(float)/low_frame/high_frame` only (NO IsLooping/Velocity/Omega per-node);
ctors with retail defaults (30f/-1/-1); `set_animation_id` full clamp order (§25);
`get_starting_frame`/`get_ending_frame` bare-int direction-aware pair (NO epsilon);
`multiply_framerate` with low/high swap on negative; `get_pos_frame` (null OOB, both overloads) /
`get_part_frame` / `has_anim`. Uses existing `IAnimationLoader` for the DBObj::Get seam.
Tests first: synthetic (all clamp branches; negative-multiply swap; direction-aware boundary
mirror table) + **dat fixtures** (real Humanoid MotionTable AnimData via DatCollection: resolve,
clamp, verify against `Animation.PartFrames.Count`).
Dependencies: P0.
**P2 — `CSequence` container + list surgery.** (closes G10, G11, G12, G14, G15, G20; G5's structural precondition)
`anim_list` (LinkedList), `first_cyclic`, `curr_anim`, `frame_number:double`, `velocity/omega`,
`placement_frame/placement_frame_id`, `hook_obj`-seam. Methods: `append_animation`
(first_cyclic-slides-to-tail-every-call + curr_anim seed), `clear/clear_animations/clear_physics`,
`remove_cyclic_anims` (snap-back + `get_ending_frame(prev)`), `remove_link_animations(n)`,
`remove_all_link_animations`, `has_anims`, `apricot`, `set_velocity/set_omega/combine_physics/
subtract_physics`, `set_placement_frame/get_curr_animframe/get_curr_frame_number`,
`multiply_cyclic_animation_fr` (framerates ONLY — G13).
Register rows in-commit: double-vs-long-double (G15); managed LinkedList vs intrusive DLList.
Tests first: list-surgery state tables (curr_anim/first_cyclic/frame_number after every op, incl.
curr_anim-inside-removed-range snaps; apricot bounded by curr_anim AND first_cyclic).
Fixture source: **synthetic** + **cdb goldens from P0** (`append_animation`/`remove_cyclic_anims`
arg sequences from a live Walk→Run→Stop cycle — replay the call sequence, assert list shape).
Dependencies: P1.
**P3 — `apply_physics` + Frame math.** (closes G7's math half)
`apply_physics(Frame, quantum, sign)` with copysign semantics; verbatim `Frame.combine`/
`Frame.subtract1`/`Frame.rotate` equivalents (port beside the existing ApplyPosFrame math, which
becomes call-compatible).
Tests first: numeric goldens — hand-computed copysign cases (±quantum × ±sign), combine∘subtract1
= identity round-trips, rotate vs quaternion reference; cross-check values against ACE
`Sequence.cs:221` + `AFrame.cs`.
Fixture source: **synthetic** (pure math; no dat needed).
Dependencies: P2 (fields), parallel-safe with P1 internals.
**P4 — `update_internal` + `update` + `advance_to_next_animation`.** (closes G3, G4, G5, G6-queue, G8, G9, G19)
Verbatim per the ACE-verified skeleton: floor(lastFrame) → advance frame_number → overshoot clamp
to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone → per-frame
crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate,
elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if
(!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook →
`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover
(per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`;
empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an
`IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point
unchanged until R6). NO safety cap.
Tests first — the R1 conformance harness core:
(a) **dat fixture**: Humanoid walk cycle advanced at fixed 1/30s quanta N ticks — golden
`frame_number` series + hook-fire (frame,direction) sequence;
(b) **synthetic**: multi-node fast-forward in one tick (lag spike) — hook ORDER across nodes +
AnimDone timing; reverse playback; exact-integer boundary landings (the G3 class); zero-framerate
node; empty-list physics fallback;
(c) **cdb goldens from P0**: advance-per-update counts + frame_number progression trace.
Dependencies: P1+P2+P3.
**P5 — adapter cutover: `AnimationSequencer` rehosted on the core.** (closes G13-split, G17-core; DELETES stale-head relocation, ClearCyclicTail surgery, per-node Velocity/Omega, safety cap, boundary-epsilon)
`SetCycle` rebuild becomes: `remove_cyclic_anims()` [+ `remove_all_link_animations` where the old
code cleared] → per-AnimData `append_animation(speed-scaled AnimData)` (= retail free-fn
`add_motion 0x005224b0`, unconditional `set_velocity/set_omega` in core) → fast path =
`change_cycle_speed`-equivalent (`multiply_cyclic_animation_fr` framerates-only) + adapter-level
velocity rescale (the R2 subtract/combine composite, kept at adapter, register row). Invented
behaviors that SURVIVE at adapter level, byte-identical: K-fix18, Fix B, stop-anim fallback,
GetLink reversed branch, velocity synthesis (each verified to still have/get its register row).
`Advance` becomes `update(dt, frame)` + `BuildBlendedFrame` reading core `curr_anim`/
`frame_number`. `PlayAction` inserts via core list ops.
Tests first: FULL existing suite green (behavior-parity is the acceptance bar) + adapter parity
tests (same SetCycle call sequences → same selected cycle/link + same hook stream as pre-cutover
recordings taken BEFORE this commit). #61's boundary-hold re-tested against verbatim boundary
math — if the flash is gone with the hold removed, delete the hold (register row retired); if
not, keep + re-file #61 with the new evidence.
Fixture source: recorded pre-cutover adapter traces (**synthetic harness**) + user visual smoke.
Dependencies: P4.
**P6 — root-motion/placement wiring + API narrowing + register sweep.** (closes G7-wiring, G14-consumers, dead-API cleanup)
`update(quantum, Frame)` output exposed to the GameWindow tick as the entity root-motion delta
(replaces dead `ConsumeRootMotionDelta` — delete it); placement-frame path wired for
anim-less objects (`get_curr_animframe` fallback); delete `Reset()`/`HasCurrentNode` or map to
`clear()`/`curr_anim != null`; `MultiplyCyclicFramerate` public surface delegates to core.
Consumers keep reading `CurrentVelocity/CurrentOmega` (adapter mirrors core `velocity/omega` +
synthesis until R3). Register reconciliation + roadmap stage-table update + memory digest note.
Fixture source: existing launch-protocol smoke (`ACDREAM_REMOTE_VEL_DIAG` off/on parity) + suite.
Dependencies: P5.
---
## 4. API MIGRATION — consumer survival through the cutover
R1 is **adapter-preserving**: every public `AnimationSequencer` member keeps its signature through
P5; the core replaces the internals. Narrowing happens only in P6 and touches only dead surface.
| Consumer (from r1-acdream-sequencer map) | Call sites | R1 impact |
|---|---|---|
| `GameWindow` spawn/fallback cycles | `:3723/3728/3732/3824` (`HasCycle`), `:3751/:3825` (`SetCycle`) | none — adapter unchanged |
| `GameWindow` jump/land/stop | `:4830` (K-fix18), `:5155/:5309/:9817` | none — K-fix18 param preserved at adapter until R3 |
| `GameWindow` NPC legacy path | `:4936` (`ApplyServerControlledVelocityCycle`) | none — path dies in R2/R6, not R1 |
| `GameWindow` local-player cycle | `:10223` | none |
| `GameWindow` anim tick | `:9876` (`Advance`), `:9882` (`ConsumePendingHooks`) | signatures unchanged; `Advance` internally becomes `update()`+blend. Hook STREAM content must be parity-tested (P5) since AnimDone timing changes gate (G5) — RemoteMotionSink/GameWindow don't consume AnimDone yet (R2 does), so risk is bounded to the hook fan-out sinks |
| `RemoteMotionSink.Commit` | `RemoteMotionSink.cs:215` (`SetCycle`), + `HasCycle`, `ApplyMotion→PlayAction` | none — sink dissolves in R2, not R1 |
| `AnimationCommandRouter` | `RouteFullCommand → SetCycle/PlayAction` | none |
| `CurrentVelocity/CurrentOmega` readers | `GameWindow.cs:9331-9334` (remote body translation), `:12917` (`AttachCycleVelocityAccessor``MotionInterpreter.GetCycleVelocity`), `MotionInterpreter` docs | semantics preserved: adapter keeps replace-gate + locomotion synthesis EXACTLY as today (G17 core/adapter split); values must be bit-identical for locomotion low-bytes — covered by P5 parity tests |
| `CurrentStyle/CurrentMotion/CurrentSpeedMod` readers | `GameWindow.cs:3723/4827/4915/4919`, `RemoteMotionSink` ctor+Commit | adapter-owned bookkeeping, untouched |
| Diagnostics `CurrentNodeDiag`/`FirstCyclicAnimRefHash`/`QueueCount` | `GameWindow.cs:9863-9871` `[CURRNODE]` block | re-expressed over core list (AnimRefHash from core node's `anim`); tuple shape kept |
| `ConsumeRootMotionDelta` | **zero callers** | deleted in P6; replaced by `update(quantum, Frame)` output |
| `Reset` / `HasCurrentNode` / external `MultiplyCyclicFramerate` | zero external callers | P6: map to `clear()` / `curr_anim != null` / core delegate, or delete |
| `AnimationHookRouter` / `IAnimationHookSink` sinks | `GameWindow.cs:9890` fan-out | unchanged in R1; hook payload type stays DatReaderWriter's `AnimationHook`. (Side note for a separate issue, NOT R1: router's silent catch-all has no logger seam — `feedback_logger_injection_for_silent_catches.md`) |
| `RenderBootstrap.SequencerFactory` | `:138/:147-174` (3-tier Setup/MotionTable fallback) | ctor signature unchanged; empty-MotionTable tier must still yield a working do-nothing sequencer (add a P5 test: empty table → `has_anims()==false` → physics-only update path, no throw) |
**Cutover invariants (P5 acceptance):** (1) full existing test suite green untouched; (2) recorded
SetCycle→hook/pose traces byte-parity vs pre-cutover for the standard protocol (walk/run/toggle/
turn/stop/jump, player+NPC); (3) every deleted invented mechanism's register row retired in the
deleting commit; every surviving adapter-level invention has a row; (4) one user visual pass at
R1 end (plan: eyes are final sanity only).

View file

@ -0,0 +1,43 @@
# R2-Q0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r2-motiontable-decomp.md` (1,603 lines, line-anchored);
the port plan with the full ambiguity table is `r2-port-plan.md` §0. This note
records the PINNED resolutions the Q1Q5 ports code against.
## Pinned
- **A1 — `get_link` branch predicate: PINNED to ACE's reading** ("EITHER speed
negative → swapped-key branch"). Three independent corroborations: ACE
`MotionTable.cs:395-426`; the working adapter's field-validated `GetLink`
(the reversed-key branch fixed the Ready→WalkBackward glitch); call-site arg
roles. The BN "both negative" reading is the same x87-flag-noise class as
`is_newer`'s garbled setcc and the R1 hook-direction swap. cdb confirmation
(bp `CMotionTable::get_link`, Ready→WalkBackward vs Ready→WalkForward)
folds into the next live retail session — non-blocking.
- **A2 — Branch-2 `signedSpeed`: PINNED to ACE** (`SubstateMod < 0 &&
speedMod > 0 → -speedMod`, the single-direction flip) pending the same cdb
session (golden: a Walk()→Walk(+) flip).
- **A3 — outTicks** = sum of each appended MotionData's `num_anims` (+ base
cycle in Branch 1/2) 1. The `action_head` rendering in BN is the packed
`num_anims` byte (decomp's own note).
- **A4 — ACE oddities adjudicated:** (1) Action-branch numAnims double-count =
ACE BUG, do not copy (retail sums outHop + actionLink [+ returnHop] only);
(2) `change_cycle_speed` old≈0 silent no-op = RETAIL (port verbatim,
including the gap); (3) `GetLinkData` 0xFFFF mask = ACE-only helper, not
ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 :
anyModifierStopOk`, port verbatim; (5) `re_modify` snapshot = deep-copied
MotionState used ONLY as the loop-termination bound — C# port deep-copies,
pops both, terminates on snapshot empty.
- **A5 — `MotionData.Bitfield`** (bit1 = substate-gated for `is_allowed`,
bit0 = clear-modifiers-on-entry): confirm on DatReaderWriter
`Types.MotionData` with a one-line Humanoid-table test at Q2.
## Q0 cdb capture (pending, non-blocking)
One live session feeds all R2 goldens: bp GetObjectSequence / get_link /
StopSequenceMotion / add_to_queue / remove_redundant_links /
truncate_animation_list / AnimationDone / CheckForCompletedMotions with
arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); protocol per
`r2-port-plan.md` §0. Until then Q2/Q3 rest on dat fixtures + synthetic
state tables + the archived 2026-05-03 walk→run trace golden (quoted in the
old Fix B comment block).

View file

@ -0,0 +1,548 @@
# ACE MotionTable / MotionTableManager port — cross-reference map
Files read in full:
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs` (615 lines)
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (251 lines)
- `references/ACE/Source/ACE.Server/Physics/Animation/AnimNode.cs` (16 lines)
- `references/ACE/Source/ACE.DatLoader/Entity/MotionData.cs` (34 lines)
- Cross-refs: `PhysicsObj.cs` (L296-300, L653-657, L899-902), `MovementManager.cs` (L118-121),
`MotionInterp.cs` (L210-231, L260), `PartArray.cs` (L52-56, L72-76, L135, L253-293, L425-435, L577-586),
`WeenieObject.cs` (L256-259)
**NOTE (important for the parent report):** ACE has TWO parallel "pending motion" trackers that
both descend from a `PhysicsObj`, and they are easy to conflate:
1. `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`) — driven by
`Table.DoObjectMotion`/`GetObjectSequence` (the **interpreted-command / high-level motion**
path). Its `AnimationDone`/`CheckForCompletedMotions` are the ones requested for this doc.
2. `MotionInterp.PendingMotions` — a *separate* list inside `MotionInterp.cs` (not covered file,
but referenced at MotionInterp.cs:210-231) that also has a `MotionDone(bool success)` method.
`MovementManager.MotionDone` calls `MotionInterpreter.MotionDone(success)`, i.e. the **raw**
motion-interp side, NOT `MotionTableManager.AnimationDone`. `MotionTableManager` and
`MotionInterp` are wired independently; `MotionTableManager` lives under `PartArray`, while
`MotionInterp` lives under `MovementManager`. Both ultimately get fed by `PhysicsObj.MotionDone`
but through different owner objects (`PartArray.MotionTableManager` vs
`MovementManager.MotionInterpreter`), and only one of the two is authoritative depending on
whether the object is server-simulated purely by object-broadcast Motion commands (uses
MotionTableManager via PartArray) vs. by the mover's own physics timestep + MoveToManager
(uses MotionInterp). Do not assume `MotionTableManager.AnimationDone` is "the" MotionDone path
for player-driven movement — cross-check which owner actually gets ticked for the acdream use
case before porting.
---
## MotionTable.cs (`ACE.Server.Physics.Animation.MotionTable`)
### Fields
```
uint ID
Dictionary<uint,uint> StyleDefaults // style -> default substate
Dictionary<uint,MotionData> Cycles // key = (style<<16)|substate -> cycle anim data
Dictionary<uint,MotionData> Modifiers // key = (style<<16)|modifierID or just modifierID -> modifier anim data
Dictionary<uint,Dictionary<uint,MotionData>> Links // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData }
uint DefaultStyle
static ConcurrentDictionary<uint,float> WalkSpeed / RunSpeed / TurnSpeed // per-motionTableID cache
```
Constructed either empty (`Allocator()`) or from the dat-loaded
`DatLoader.FileTypes.MotionTable` (straight field copy, no transform — L40-48).
### `DoObjectMotion(motion, currState, sequence, speedMod, ref numAnims)`
Trivial forwarder: `return GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers: false);`
(L55-58)
### `GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers)` — L60-257
The core state-transition dispatcher. `numAnims` is reset to 0 up front.
Early-out: if `currState.Style == 0 || currState.Substate == 0``false` (uninitialized state).
Looks up `substate = StyleDefaults[currState.Style]` (the *default substate for the current
style*, e.g. "Standing" under style "NonCombat").
**Guard (L73-74):** if `motion == substate` (i.e. caller is asking to enter the style's own
default substate) AND `!stopModifiers` AND current substate already has the Modifier bit set
(`CommandMask.Modifier`) → return `true` immediately (no-op — already effectively there via a
modifier).
Then four command-mask branches, checked with **plain OR semantics — NOT else-if.** Each branch
can independently fire and `return true` from inside if it succeeds; falling out of a branch
(motionData null, is_allowed false, etc.) falls through to the next mask check.
**`CommandMask.Style` branch (L76-120)** — motion requests a stance/style change (e.g. switch
combat stance):
- If `currState.Style == motion` already → `true` (no-op).
- If `substate != currState.Substate`: compute `motionData = get_link(currState.Style,
currState.Substate, currState.SubstateMod, substate, speedMod)` — the transition INTO the new
style's default substate from the current substate.
- If `substate != 0`: look up `cycles = Cycles[(motion<<16)|substate]` — the cycle anim for the
new style's default substate.
- If found:
- `(cycles.Bitfield & 1) != 0``currState.clear_modifiers()` (bit 1 = "clears modifiers on
entry", e.g. entering a stance that can't carry over swimming/etc modifiers).
- `link = get_link(currState.Style, substate, currState.SubstateMod, motion, speedMod)`
transition from (old style, NEW style's default substate) to the target style itself.
- **Fallback chain if `link == null` and `currState.Style != motion`:** re-resolve via
`DefaultStyle``link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f)`,
then `motionData_ = get_link(DefaultStyle, StyleDefaults[DefaultStyle], 1.0f, motion, 1.0f)`.
This is the "no direct link exists, route through the global default style" path (e.g.
Standing).
- `sequence.clear_physics(); sequence.remove_cyclic_anims();` — wipe outstanding velocity/
omega contributions and any looping cycle anims before splicing in the new chain.
- Append in strict order: `add_motion(motionData)`, `add_motion(link)`,
`add_motion(motionData_)`, `add_motion(cycles)` — i.e. [transition-to-default-substate] →
[transition-to-target-style] → [fallback-via-default-style, usually null] →
[new cycle]. Each `add_motion` no-ops silently on null `motionData`.
- Commits state: `currState.Substate = substate; currState.Style = motion; currState.SubstateMod = speedMod;`
- `re_modify(sequence, currState)` — replays any still-active modifiers (see below) on top
of the newly spliced sequence.
- `numAnims = sum of each non-null MotionData's Anims.Count, minus 1` (the `-1` is
because the queue entry itself represents completion of ONE playthrough of the LAST
appended motion's cycle, not a raw anim count — cross-check against `add_to_queue` in
MotionTableManager, which stores this count as `AnimNode.NumAnims`, i.e. the number of
"hook" firings, one per queued sub-anim minus a terminal borrow).
- Returns `true`.
**`CommandMask.SubState` branch (L121-188)** — motion requests a substate change within the
current style (e.g. Standing → Crouching), OR a coalesced-speed no-op update to the CURRENT
cycle:
- `motionID = motion & 0xFFFFFF` (strip command-class bits).
- `motionData = Cycles[(currState.Style<<16)|motionID]`, falling back to
`Cycles[(DefaultStyle<<16)|motionID]` if the current style doesn't define that substate.
- If found and `is_allowed(motion, motionData, currState)` (see below):
- **Speed-only fast path (L132-139):** if `motion == currState.Substate` AND
`sequence.HasAnims()` AND `Math.Sign(speedMod) == Math.Sign(currState.SubstateMod)` — i.e.
caller is re-issuing the SAME substate at a new speed, same direction of travel (fwd vs
reverse) — then: `change_cycle_speed` (rescale the already-playing cyclic anim's framerate),
`subtract_motion` (remove the old velocity/omega contribution at the old speed),
`combine_motion` (add back at the new speed), update `currState.SubstateMod = speedMod`,
return `true`. **No new anims queued, no sequence splice** — this is the "already running,
just changing speed" branch (e.g. walk→run without breaking stride).
- Otherwise (new substate, or sign flip == direction reversal):
- `(motionData.Bitfield & 1) != 0``currState.clear_modifiers()`.
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`
— direct transition from current substate to target substate.
- **Fallback (L145-151):** if `link == null` OR the sign of `speedMod` differs from
`currState.SubstateMod` (direction reversal, e.g. forward↔backward) — route through the
style's default substate: `link = get_link(currState.Style, currState.Substate,
currState.SubstateMod, defaultMotion, 1.0f)` (transition out to default),
`motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod)`
(transition from default into target).
- `sequence.clear_physics(); sequence.remove_cyclic_anims();`
- If `motionData_ != null` (fallback path taken): append `link` at `currState.SubstateMod`,
then `motionData_` at `speedMod`.
- Else (direct link path): `newSpeedMod = speedMod`, but **flip sign** if
`currState.SubstateMod < 0 && speedMod > 0` (i.e. reversing FROM negative — asymmetric
handling, only corrects one direction of sign mismatch, not both) — append `link` at
`newSpeedMod`.
- Always append `motionData` (the new cycle) at `speedMod`.
- **Modifier carry-over (L170-176):** if the OLD substate differs from the new `motion` AND
the old substate had the Modifier bit set, AND the old substate isn't just the style's
default motion, re-add it as an active modifier via `currState.add_modifier_no_check`
(i.e. modifiers riding on a substate survive a substate change unless they equal the new
target or the style default).
- Commit `currState.SubstateMod = speedMod; currState.Substate = motion;`, then `re_modify`.
- `numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1`
(nulls treated as 0).
- Returns `true`.
**`CommandMask.Action` branch (L189-233)** — one-shot action motions (attacks, jumps, etc,
things layered on TOP of a cycle without replacing it):
- `cycleKey = (currState.Style<<16)|(currState.Substate & 0xFFFFFF)`; `motionData =
Cycles[cycleKey]` (the CURRENT cycle, must exist).
- If found:
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`.
- **If `link != null` (direct action link exists):** `currState.add_action(motion, speedMod)`
(push onto the action stack — see MotionState, not read this pass), clear physics/cyclic,
append `link` at `speedMod` then re-append `motionData` (the ORIGINAL cycle, at the OLD
`currState.SubstateMod` — i.e. resume the cycle after the action plays), `re_modify`,
`numAnims = link.Anims.Count` (note: NOT including motionData's count here — only the
action-link portion counts toward completion tracking, the re-appended base cycle is
presumably a normal looping anim not subject to the same "done" semantics).
- **Else (no direct action link — fallback via style default, L209-231):**
`motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, 1.0f)`
(transition current substate → style's own default substate) as a NEW `motionData`
(shadows the cycle lookup above). If that resolves: `link = get_link(currState.Style,
substate, 1.0f, motion, speedMod)` (default substate → action), and re-fetch `cycles =
Cycles[cycleKey]` (original cycle again). If `link != null` and `cycles` found:
`motionData_ = get_link(currState.Style, substate, 1.0f, currState.Substate,
currState.SubstateMod)` (default substate → back to original substate, for resuming after).
`currState.add_action(...)`, clear physics/cyclic, append in order `motionData` (→default),
`link` (default→action), `motionData_` (default→back), `cycles` (resume original cycle at
`currState.SubstateMod`). `re_modify`. `numAnims = motionData.Anims.Count +
link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)` — **NOTE: this last
term reads `motionData.Anims.Count` again, NOT `motionData_.Anims.Count` — looks like a copy-
paste bug in ACE's port** (compare to the Style/SubState branches which correctly sum each
distinct MotionData). Flag this explicitly when cross-checking against 2013 retail —
likely a genuine ACE divergence, not a retail behavior to replicate.
**`CommandMask.Modifier` branch (L234-255)** — continuous modifiers layered on the current cycle
(e.g. sneaking, aiming overlay) that don't interrupt it:
- `styleKey = currState.Style<<16`; `cycles = Cycles[styleKey|(currState.Substate&0xFFFFFF)]`
(current cycle must exist).
- If found AND `(cycles.Bitfield & 1) == 0` (cycle does NOT forbid modifiers):
- `motionData = Modifiers[styleKey|(motion&0xFFFFFF)]`, falling back to
`Modifiers[motion&0xFFFFFF]` (style-agnostic modifier) if not found.
- If found:
- `if (!currState.add_modifier(motion, speedMod))` — if the state rejects adding this
modifier (e.g. already present / list full):
- `StopSequenceMotion(motion, 1.0f, currState, sequence, ref numAnims)` — force-remove it
first, then retry `add_modifier`. If STILL fails, return `false`.
- `combine_motion(sequence, motionData, speedMod)` — layer the modifier's velocity/omega +
anims onto the sequence WITHOUT clearing physics or cyclic anims (additive, unlike the
other three branches which splice/replace).
- Returns `true`. **No numAnims write here — stays whatever it was reset to (0) at entry,
i.e. modifiers are not tracked for animation-completion purposes.**
Falls through all four branches unmatched → `return false`.
### `Get(uint motionTableID)` — static factory, L259-264
Directly `DatManager.PortalDat.ReadFromDat<FileTypes.MotionTable>(id)` wrapped in `new
MotionTable(...)`. Comment `//return ObjCache.GetMotionTable(mtableID);` — retail apparently
caches motion tables by ID; ACE re-reads from dat every call (no caching layer here, though the
dat reader itself likely caches file reads elsewhere).
### `SetDefaultState(state, sequence, ref numAnims)` — L266-291
Resets to the table's global default (style, substate) pair:
- `defaultSubstate = StyleDefaults[DefaultStyle]`; if missing → `false`.
- `state.clear_modifiers(); state.clear_actions();`
- `cycle = (DefaultStyle<<16)|defaultSubstate`; `motionData = Cycles[cycle]`; if missing → `false`.
- `numAnims = motionData.Anims.Count - 1`.
- `state.Style = DefaultStyle; state.Substate = defaultSubstate; state.SubstateMod = 1.0f;`
- `sequence.clear_physics(); sequence.clear_animations();` (note: `clear_animations`, not
`remove_cyclic_anims` — a harder reset, used only here and presumably at spawn/enter-world).
- `add_motion(sequence, motionData, 1.0f)`.
### `StopObjectCompletely(currState, sequence, ref numAnims)` — L293-313
Iterates and removes ALL active modifiers via `StopSequenceMotion` (loop drains
`currState.Modifiers.First` until empty — relies on `StopSequenceMotion` removing the head each
call), tracking whether ANY modifier stop succeeded (`success`). Then stops the current substate
itself: `StopSequenceMotion(currState.Substate, currState.SubstateMod, ...)`. Returns `true` if
EITHER the substate-stop succeeded OR any earlier modifier-stop succeeded (`success ||
substateStopSucceeded`, though written as an if/else that returns `true` whenever the final
substate-stop call returns non-... — re-read: `if (!StopSequenceMotion(...)) return success; else
return true;` — i.e. final result is `true` unless the LAST stop call fails, in which case it
falls back to whatever `success` was from the modifier loop).
### `StopObjectMotion` / `StopSequenceMotion` — L315-356
`StopObjectMotion` is a trivial forwarder to `StopSequenceMotion`.
`StopSequenceMotion(motion, speed, currState, sequence, ref numAnims)`:
- `numAnims = 0`.
- **SubState case:** if `(motion & CommandMask.SubState) != 0 && currState.Substate == motion`
i.e. caller wants to stop the CURRENT substate → resolve the style's default substate and
re-enter it via `GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims,
stopModifiers: true)` (recursion into the main dispatcher with `stopModifiers=true`, which
suppresses the early "already at default via modifier" guard at L73). Returns `true`
unconditionally after this call (return value of the inner `GetObjectSequence` is discarded).
- **Non-modifier, non-matching-substate case:** if `(motion & CommandMask.Modifier) == 0`
`false` (nothing to stop — motion isn't a substate-stop or a modifier).
- **Modifier case:** linear-scan `currState.Modifiers` linked list for a node whose `.ID ==
motion`. On match:
- `key = (currState.Style<<16)|(motion&0xFFFFFF)`; `Modifiers[key]`, falling back to
`Modifiers[motion&0xFFFFFF]`. If neither resolves → `false`.
- `subtract_motion(sequence, motionData, modifier.Value.SpeedMod)` — reverse the modifier's
velocity/omega contribution using its ORIGINAL speed (not the `speed` parameter passed in —
`speed` param appears unused in this branch; only used implicitly via the SubState-case call
above). `currState.remove_modifier(modifier)`. Returns `true`.
- No match found after scanning entire list → `false`.
### `add_motion` / `combine_motion` / `subtract_motion` / `change_cycle_speed` — L358-393
- **`add_motion(sequence, motionData, speed)`:** no-op if `motionData == null`. Otherwise
`sequence.SetVelocity(motionData.Velocity * speed)`, `sequence.SetOmega(motionData.Omega *
speed)` (REPLACES, not adds — "Set" not "Combine"), then for each anim in `motionData.Anims`
wraps as `new AnimData(anim, speed)` and `sequence.append_animation(animData)`.
- **`combine_motion(sequence, motionData, speed)`:** no-op if null. Otherwise
`sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed)` — additive
variant used by the Modifier branch (doesn't touch the anim queue at all, only velocity/omega).
- **`subtract_motion(sequence, motionData, speed)`:** no-op if null. `sequence.subtract_physics
(motionData.Velocity * speed, motionData.Omega * speed)` — inverse of combine, used when
removing a modifier or an old-speed cycle contribution.
- **`change_cycle_speed(sequence, motionData, substateMod, speedMod)`:** if
`|substateMod| > PhysicsGlobals.EPSILON` → `sequence.multiply_cyclic_animation_framerate
(speedMod/substateMod)` (rescale by the RATIO of new to old speed). Else-if `|speedMod| <
EPSILON` → `multiply_cyclic_animation_framerate(0)` (freeze the anim — old speed was ~0 so no
ratio is definable, new speed is also ~0). **Gap: if `substateMod` ~0 AND `speedMod` is
NON-zero, neither branch fires — no framerate change is applied.** Worth checking against
retail whether this is a real edge case (resuming a stopped cycle at nonzero speed without a
ratio) — could be a silent no-op bug carried from retail or an ACE gap.
### `get_link(style, substate, substateSpeed, motion, speed)` — L395-426
Direction-aware transition lookup with two symmetric lookup CHAINS depending on sign of the
speeds:
- **If EITHER `speed < 0` or `substateSpeed < 0` (reverse-direction transition):** look up
`Links[(style<<16)|(motion&0xFFFFFF)]` (keyed by DESTINATION motion) then `.TryGetValue
(substate, ...)` (indexed by SOURCE substate) — i.e. reversed key order vs the forward case.
If that fails, fall back through `StyleDefaults[style]` and
`Links[(style<<16)|(substate&0xFFFFFF)]``.TryGetValue(defaultMotion, ...)`.
- **Else (forward / non-negative speeds):** look up `Links[(style<<16)|(substate&0xFFFFFF)]`
(keyed by SOURCE substate) then `.TryGetValue(motion, ...)` (indexed by DESTINATION). Fallback:
`Links[style<<16]` (style-wide, substate-agnostic) → `.TryGetValue(motion, ...)`.
- Returns `null` if nothing resolves in either chain.
This encodes retail's dat-side Links table having asymmetric (from,to) vs (to,from) storage
depending on animation reversibility — reverse playback (e.g. walking backward) reuses the
FORWARD anim's link table keyed the other way around rather than storing a mirrored copy.
### `is_allowed(motion, motionData, state)` — L428-438
`false` if `motionData == null`. `true` if `(motionData.Bitfield & 2) == 0` (bit 2 = "always
allowed" flag) OR `motion == state.Substate` (re-entering the same substate is always allowed
regardless of the bit). Otherwise: only allowed if the CURRENT substate IS the style's own
default substate (`StyleDefaults[state.Style] == state.Substate`) — i.e. bit-2-gated substates
can only be entered FROM the style's default/neutral substate, not chained from an arbitrary
other substate.
### `re_modify(sequence, pstate)` — L440-458
No-op if `pstate.Modifiers.First == null`. Otherwise snapshots `pstate` into a NEW `MotionState
state = new MotionState(pstate)` (deep-ish copy incl. its own Modifiers list), then drains
`pstate`'s modifier list one node at a time: pops `speedMod`/`motion` off `pstate.Modifiers.First`,
removes the SAME logical entry from BOTH `pstate` and the snapshot `state` (odd double-removal —
removing from the snapshot copy seems purposeless unless `MotionState`'s copy ctor shares the
underlying list nodes, in which case this is defensive against aliasing), then calls
`GetObjectSequence(motion, pstate, sequence, speedMod, ref numAnims, stopModifiers: false)` to
RE-APPLY each modifier on top of the now-current (post-transition) `pstate`. This is how the
Style/SubState branches "carry forward" active modifiers across a cycle-changing transition —
after splicing the new base cycle in, `re_modify` walks the still-active modifier list (which at
that point is whatever the branch didn't already clear) and re-runs each one through the full
dispatcher so its layered anims/physics get re-appended onto the NEW sequence.
### Static helpers (L460-613)
- **`GetAttackFrames(motionTableId, stance, motion)`** — dat-lookup passthrough to
`DatLoader.FileTypes.MotionTable.GetAttackFrames` (not in this file). Returns cached
`emptyList` for `motionTableId == 0`.
- **`GetAnimationLength(motionTableId, stance, motion, speed=1)`** / the 2-motion overload that
also takes `currentMotion` — the latter, if `motion` has the Style bit set and `currentMotion
!= Ready`, first adds the Ready→currentMotion transition length, forces `currentMotion =
Ready`, THEN adds `currentMotion→motion`. Divides everything by `speed`.
- **`GetCycleLength`** — dat passthrough / speed.
- **`GetRunSpeed(motionTableID)`** — cached in static `RunSpeed` dict. Computes via
`GetMotionData(id, MotionCommand.RunForward)``GetAnimDist(motionData)`.
- **`GetTurnSpeed(motionTableID)`** — cached in static `TurnSpeed` dict.
`Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z)`.
- **`GetMotionData(motionTableID, motion, currentStyle=null)`** — resolves `currentStyle` to
`motionTable.DefaultStyle` if unspecified, strips command bits (`motion & 0xFFFFFF`), looks up
`Cycles[(style<<16)|motionID]`.
- **`GetLinkData(motionTableID, motion, currentStyle=null)`** — looks up
`Links[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)]` (**NOTE: masks with `0xFFFF` here,
NOT `0xFFFFFF` like everywhere else in this file — inconsistent mask width, likely harmless
since `MotionCommand.Ready`'s low bits fit in 16 bits, but worth flagging as an ACE
inconsistency if porting verbatim**), then `.TryGetValue(motion, ...)`.
- **`GetAnimDist(motionData)`** — sums `frame.Origin` across every `PosFrames` frame of every
anim in `motionData.Anims` (reads each `Animation` fresh from dat by `anim.AnimId`), takes
`.Length()` of the summed offset vector, divides by `totalFrames`, multiplies by
`motionData.Anims[0].Framerate` → "distance per second". Returns 0 if the vector length is 0.
- **`HasDefaultScript(motionTableID, motion, currentStyle)`** — `GetLinkData` then scans every
anim's `PartFrames[*].Hooks` for `AnimationHookType.DefaultScript`.
---
## MotionTableManager.cs (`ACE.Server.Physics.Managers.MotionTableManager`) — owned by `PartArray`
### Fields
```
PhysicsObj PhysicsObj
MotionTable Table
MotionState State
uint AnimationCounter
LinkedList<AnimNode> PendingAnimations
```
`AnimNode { uint Motion; uint NumAnims; }` (trivial struct-like class, `AnimNode.cs`).
### `AnimationDone(bool success)` — L28-61
Called from `PartArray.AnimationDone``PhysicsObj.Hook_AnimDone()` (fired when a per-frame
animation HOOK signals completion — the frame-based `Hook_AnimDone` callback, NOT a polling
check). Logic:
- If `PendingAnimations.First == null` → no-op (nothing pending).
- `AnimationCounter++` (one hook fired, counts toward the FIRST queued `AnimNode`'s
`NumAnims` threshold).
- **Loop** while `node != null`:
- `entry = node.Value`. If `entry.NumAnims > AnimationCounter`**break** (head entry hasn't
accumulated enough hook-fires yet — stop, this increment wasn't enough to finish it).
- If entry's motion has the Action bit set → `State.remove_action_head()` (pop the action
stack — the completed queue entry was an action, so remove its tracking entry from
`MotionState`).
- `motionID = entry.Motion`; `PhysicsObj.MotionDone(motionID, success)` — fires the
completion callback chain (→ `MovementManager.MotionDone``MotionInterpreter.MotionDone`,
see note at top: this is the OTHER pending-motion tracker, decoupled from this one except by
sharing the `PhysicsObj` "owner").
- `AnimationCounter -= entry.NumAnims` (consume the threshold — any EXCESS hooks beyond this
entry's requirement roll over to satisfy the NEXT queued entry, hence the outer `do...while`
loop can complete MULTIPLE queue entries from ONE `AnimationDone` call if `AnimationCounter`
still exceeds the next entry's `NumAnims`).
- If `PendingAnimations.First != null``RemoveFirst()` (dequeue the just-completed entry).
- If `PhysicsObj.WeenieObj != null``WeenieObj.OnMotionDone(motionID, success)` (separate
weenie-level hook, forwards to `WorldObject.HandleMotionDone` — the game-logic-facing
callback, distinct from the physics-level `PhysicsObj.MotionDone`).
- `node = PendingAnimations.First` (re-check for another completable entry).
- After the loop: if `AnimationCounter != 0 && node == null` (queue fully drained but counter
still has leftover) → **reset `AnimationCounter = 0`** (defensive clamp — discards any
leftover hook-credit once nothing remains queued, rather than carrying it forward to a future
`add_to_queue` call).
### `CheckForCompletedMotions()` — L63-85
Called from `PartArray.CheckForCompletedMotions``PhysicsObj.CheckForCompletedMotions()`
this one is POLLED (search found no evidence of a per-frame automatic caller within these two
files; likely ticked once per physics update from `PhysicsObj.UpdateObjectInternal` or similar,
not confirmed in this pass — flag for follow-up if the caller chain matters). Distinct from
`AnimationDone`: this drains any HEAD entries whose `NumAnims == 0` **already** (i.e. entries
that never needed any hook fires to complete — e.g. zero-length transitions), NOT
counter-driven:
- Loop while `PendingAnimations.First != null`:
- If `pendingAnimation.Value.NumAnims != 0`**return** (head isn't a zero-length entry —
stop, do NOT touch `AnimationCounter`, this is purely for immediate 0-anim entries).
- Otherwise: pop the same way as `AnimationDone` (Action-bit → `RemoveActionHead`,
`PhysicsObj.MotionDone(motionID, true)` (always `success=true` here — no `success` param on
this method), remove from list, `WeenieObj.OnMotionDone(motionID, true)`.
- Continues looping (could drain several consecutive 0-`NumAnims` entries in one call).
### `PerformMovement(mvs, seq)` — L116-145
Dispatches on `mvs.Type` (`MovementStruct.Type`):
- `Table == null``WeenieError.NoAnimationTable`.
- **`InterpretedCommand`:** `Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref
counter)`; if it returns `false` → `WeenieError.NoMtableData`. Else `add_to_queue(mvs.Motion,
counter, seq)`, return `None`.
- **`StopInterpretedCommand`:** `Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State,
seq, ref counter)`; failure → `NoMtableData`. Success → `add_to_queue((uint)MotionCommand.Ready,
counter, seq)` (**note: queues under `MotionCommand.Ready`, NOT `mvs.Motion`** — a stop always
enqueues completion-tracking keyed to Ready, regardless of what was stopped).
- **`StopCompletely`:** `Table.StopObjectCompletely(State, seq, ref counter)` (return value
ignored), `add_to_queue((uint)MotionCommand.Ready, counter, seq)`, return `None`
unconditionally.
- **default:** `WeenieError.None` (comment `// ??` — ACE itself flags this as uncertain; other
`MovementType` values like `RawCommand`/`StopRawCommand` fall through here untouched by this
manager, presumably handled instead by `MotionInterp.PerformMovement`).
### `SetMotionTableID(mtableID)``Table = MotionTable.Get(mtableID); return Table != null;`
### `UseTime()``CheckForCompletedMotions();` — the manager's per-tick entry point (called
from `PartArray.cs:265`, itself presumably ticked from `PhysicsObj`'s per-update pass — same
follow-up caveat as above).
### `add_to_queue(motion, num_anims, sequence)` — L163-167
`PendingAnimations.AddLast(new AnimNode(motion, num_anims))`, then immediately
`remove_redundant_links(sequence)` — every enqueue triggers a redundancy pass over the WHOLE
list (not just checking the new tail against its immediate predecessor blindly; see below).
### `initialize_state(sequence)` — L169-177
`numAnims = 0`; if `Table != null``Table.SetDefaultState(State, sequence, ref numAnims)`.
Always `add_to_queue((uint)MotionCommand.Ready, numAnims, sequence)` regardless of whether
`SetDefaultState` succeeded (numAnims stays 0 on failure, so this enqueues a same-tick-complete
Ready entry).
### `remove_redundant_links(sequence)` — L179-205
Walks `PendingAnimations` from **tail to head** (`node = PendingAnimations.Last`, then
`node.Previous`). For each entry with `NumAnims != 0`:
- If the entry's motion does NOT have the SubState bit set, OR it DOES have the Modifier bit set
(i.e. it's a Style/Action/Modifier-class entry, not a plain substate cycle): only proceed if
it also has the Style bit set (`entry.Motion & CommandMask.Style`), else **return** (stop
scanning entirely — non-style, non-substate-eligible entries block further redundancy
checking). If Style-bit set: call `remove_redundant_links_inner(node, sequence, first: true)`;
if it returns `true`**return** (done, something was truncated).
- Else (a plain substate-cycle entry): `remove_redundant_links_inner(node, sequence, first:
false)`; if `true` → return.
- Otherwise continue to `node = node.Previous` (walk further back toward the head).
### `remove_redundant_links_inner(node, sequence, first)` — L207-231
Scans BACKWARD from `node.Previous` looking for an earlier entry with the SAME `Motion` value:
- `motion = first ? 0x70000000 : 0xB0000000` — these are raw `CommandMask`-shaped bit patterns
used as an early-abort guard (0x70000000 covers Style|SubState|Action bits per typical AC
command-mask layout; 0xB0000000 a different combination — exact bit semantics live in
`CommandMask` enum, not read this pass, but functionally: differing abort masks depending on
whether we're scanning for a Style-class or SubState-class duplicate).
- While walking back (`prev = prev.Previous` each iteration):
- If `prevEntry.Motion == entry.Motion` AND (`first` is true, OR `prevEntry.NumAnims != 0`) →
found a duplicate → `trancuate_animation_list(prev, sequence)`, return `true`.
- Else if `prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0` → an intervening entry
of the "abort mask" class with pending anims blocks the search → return `true` WITHOUT
truncating (stops the outer loop but did nothing — prevents redundancy removal across a
still-animating Style/Action boundary).
- Else continue.
- If the walk exhausts (`prev == null`) without matching → return `false` (caller's outer loop
continues scanning further back from the ORIGINAL node).
### `trancuate_animation_list(node, sequence)` — L233-249 (note: "trancuate" — misspelling of
"truncate" preserved verbatim from ACE source, matches the mismatched name used at the call
site `remove_redundant_links_inner`)
Walks from `PendingAnimations.Last` backward toward (but not including) `node`: sums every
visited entry's `NumAnims` into `totalAnims`, then **zeroes each entry's `NumAnims` in place**
(`entry.Value.NumAnims = 0` — does NOT remove them from the list, just neuters their
completion-tracking contribution). Finally `sequence.remove_link_animations(totalAnims)` — tells
the `Sequence` to physically drop that many trailing "link" animations from its playback queue
(the actual anim-clip splice), while `PendingAnimations` keeps the now-inert `AnimNode` entries
in place with `NumAnims=0` (they'll be silently skipped/instantly-completed by
`CheckForCompletedMotions`'s 0-check, or bypass `AnimationDone`'s counter entirely since their
threshold is unreachable-but-trivially-satisfied at 0... actually re-check: `AnimationDone`'s
break condition is `entry.NumAnims > AnimationCounter`; with `NumAnims == 0` this is never true,
so a zeroed entry always immediately qualifies for completion processing on the NEXT
`AnimationDone` call, consistent with `CheckForCompletedMotions` also draining 0-count heads).
---
## MotionDone hook chain (full call graph, both trackers)
```
PhysicsObj.Hook_AnimDone() [per-frame anim-hook fire from Sequence/AFrame dispatch]
-> PartArray.AnimationDone(true)
-> MotionTableManager.AnimationDone(true) [drains PendingAnimations by counter]
-> PhysicsObj.MotionDone(motionID, success)
-> MovementManager.MotionDone(motion, success)
-> MotionInterpreter.MotionDone(success) [SEPARATE tracker: MotionInterp.PendingMotions]
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success)
-> WorldObject.HandleMotionDone(motionID, success) [game-logic level]
PhysicsObj.CheckForCompletedMotions() [polled, presumably per physics tick]
-> PartArray.CheckForCompletedMotions()
-> MotionTableManager.CheckForCompletedMotions() [drains 0-NumAnims heads only]
-> (same PhysicsObj.MotionDone / WeenieObj.OnMotionDone chain as above)
MotionInterp.MotionDone(success) [the OTHER path — NOT reached via MotionTableManager]
-> pops MotionInterp.PendingMotions.First
-> if Action bit set: PhysicsObj.unstick_from_object(), InterpretedState.RemoveAction(),
RawState.RemoveAction()
-> PhysicsObj.IsAnimating = PendingMotions.Count > 0
```
**Key divergence risk for acdream:** `MovementManager.MotionDone` ALWAYS routes to
`MotionInterpreter.MotionDone`, never to `MotionTableManager`. The two trackers are fed by
DIFFERENT owners (`PartArray.MotionTableManager` vs `MovementManager.MotionInterpreter`) and
`PhysicsObj.MotionDone(motion, success)` only calls the `MovementManager` one
(`PhysicsObj.cs:899-902`). `MotionTableManager.AnimationDone`/`CheckForCompletedMotions` DO call
`PhysicsObj.MotionDone` internally (feeding MotionInterp), but nothing in these two files calls
BACK from `MotionInterp` into `MotionTableManager` — i.e. `MotionTableManager` is upstream of
`MotionInterp` in the completion-notification chain, not a peer. When porting to acdream's
`CMotionInterp`-based unification (per the D6.2a work already landed), confirm which ACE class
maps to which acdream responsibility — likely `MotionTableManager` ≈ acdream's discrete-command
completion queue (attached per-PartArray/per-object), `MotionInterp` ≈ acdream's
`CMotionInterp`-normalized local-player path (attached per-MovementManager). Do not assume they
merge into one queue — ACE keeps them structurally separate even though both trace back to the
same `PhysicsObj`.
## Flagged ACE-side oddities (verify against 2013 retail decomp before porting verbatim)
1. **Action-branch numAnims miscalculation** (`GetObjectSequence` L227): fallback Action path
sums `motionData.Anims.Count` TWICE instead of once for `motionData` and once for
`motionData_` — looks like a copy-paste error in ACE's port, not necessarily retail-faithful.
2. **`change_cycle_speed` silent no-op gap** (L372-379): when `substateMod ≈ 0` AND `speedMod`
is nonzero, neither branch fires — no framerate adjustment applied. Check retail's
`change_cycle_speed` equivalent for a third branch.
3. **`GetLinkData` mask width inconsistency** (L562): uses `& 0xFFFF` where every other
motion-ID mask in this file uses `& 0xFFFFFF`. Likely harmless (Ready's ID fits in 16 bits)
but inconsistent.
4. **`StopObjectCompletely` return-value semantics** (L293-313): returns `true` unless the FINAL
substate-stop call fails, in which case it falls back to whatever `success` was from the
modifier-draining loop — easy to misport if simplified to a single boolean accumulate.
5. **`re_modify` double-removal from both `pstate` and a snapshot `state`** (L440-458): only
makes sense if `MotionState`'s copy constructor shares the underlying `LinkedList<Motion>`
nodes with the source, which would make the second `state.remove_modifier(...)` call either
redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list —
not verifiable without reading `MotionState.cs`'s copy ctor (out of scope this pass).
## Files NOT read this pass (would need separate grep if pursued)
- `MotionState.cs` (referenced constantly: `.Modifiers`, `.add_modifier`,
`.add_modifier_no_check`, `.remove_modifier`, `.clear_modifiers`, `.add_action`,
`.remove_action_head`, `.clear_actions`, copy constructor semantics)
- `MotionInterp.cs` full file (only L190-260 read; `PendingMotions`, `enter_default_state`,
`apply_current_movement`, `get_leave_ground_velocity` not traced)
- `Sequence.cs` (`SetVelocity`, `SetOmega`, `CombinePhysics`, `subtract_physics`,
`append_animation`, `clear_physics`, `remove_cyclic_anims`, `clear_animations`,
`remove_link_animations`, `multiply_cyclic_animation_framerate`, `HasAnims`,
`remove_all_link_animations`)
- `CommandMask` enum exact bit values (Style/SubState/Action/Modifier)
- `WeenieObject.HandleMotionDone` in `AC.Server` (game-logic side, outside Physics/)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,299 @@
# R2 port work-list — GetObjectSequence + MotionTableManager
Inputs: `r2-motiontable-decomp.md` (verbatim retail extraction, this scratchpad),
`r2-ace-motiontable.md` (ACE cross-ref, this scratchpad), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R2), R1 gap map
`docs/research/2026-07-02-r1-csequence/r1-gap-map.md`, R1 core
`src/AcDream.Core/Physics/Motion/{CSequence,AnimSequenceNode,FrameOps}.cs`, adapter
`src/AcDream.Core/Physics/AnimationSequencer.cs`, S2b sink
`src/AcDream.App/Rendering/RemoteMotionSink.cs`.
**Precondition / state at R2 start:** R1 commits P0P4 are landed (`1371c2a1`,
`778744bf`, `5138b8fb`, `658b91d8`); the P5 adapter cutover is IN the working tree
(AnimationSequencer already rehosted on `_core: CSequence`) but **uncommitted**, and
P6 (root-motion wiring + register sweep) is not started. **R2 depends on P5+P6 being
committed first.** Every CSequence primitive R2 needs already exists in the R1 core:
`AppendAnimation` (first_cyclic-slides, G10), `RemoveCyclicAnims`,
`RemoveLinkAnimations(count)`, `RemoveAllLinkAnimations`, `ClearPhysics`,
`Combine/SubtractPhysics`, `MultiplyCyclicAnimationFramerate` (framerates-only, G13),
`HasAnims`, and the `IAnimHookQueue.AddAnimDoneHook` seam (G5).
---
## 0. Decomp ambiguities to pin BEFORE porting (the Q0 pseudocode commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| A1 | **`get_link` branch predicate is likely INVERTED in the BN extraction.** r2-motiontable-decomp §4 reads "both speed_mods negative → branch B (swap keys)". But under that reading the NORMAL forward case (both positive) takes branch A = outer hash keyed by **to**-substate, which contradicts (a) the dat Links layout the working adapter uses (outer key = `(style<<16)\|fromSubstate`, `AnimationSequencer.cs:989`), (b) ACE `MotionTable.cs:395-426` ("EITHER negative → reversed keys"), and (c) the call sites' arg roles (`get_link(style, currentSubstate, mod, targetSubstate, speed)`). If the predicate is "**either** negative → swapped-key branch", retail branch A == ACE's reverse branch and branch B == ACE's forward branch, and BOTH fallback blocks align exactly (either-neg fallback = style_defaults hop = ACE reverse fallback; both-pos fallback = `links[style<<16][to]` = ACE forward fallback). Same x87-flag-noise class as `same_sign` (§2) and the r1 gap-map hook-direction fix. | BN §4 vs ACE L395-426 + adapter GetLink (user-validated in the field: the reversed-key branch fixed the "left leg twitches" Ready→WalkBackward glitch) | Re-read the raw pseudo-C at @298552 for the flag test; if still ambiguous, cdb bp `acclient!CMotionTable::get_link` logging args + which hash bucket is probed during a Ready→WalkBackward start vs Ready→WalkForward start. Default resolution if cdb unavailable: ACE's reading (three independent corroborations). |
| A2 | **Branch-2 `signedSpeed` block** (no-double-hop leg): decomp cleans it as `(substate_mod==0 \|\| same_sign(substate_mod,arg5)) ? arg5 : -arg5` (§5 line ~550); ACE flips only one direction (`SubstateMod < 0 && speedMod > 0 → -speedMod`, MotionTable.cs:155-166). | BN x87 noise vs ACE asymmetry | Re-read raw @~0x00522bXX; conformance test both readings against a cdb golden of a Walk(-)→Walk(+) flip. |
| A3 | **outTicks field decode**: BN renders one term as `arg3->action_head` — actually MotionData's packed `num_anims` byte (decomp's own note, §5). All outTicks arithmetic = sum of each appended MotionData's `num_anims` (link chains) [+ base-cycle num_anims in Branch 1/2] 1. | decomp note + ACE numAnims sums | Textual pin only; assert in tests: outTicks(walk→run direct) == link.num_anims + cycle.num_anims 1. |
| A4 | **Adjudicate the 5 flagged ACE oddities** (r2-ace-motiontable "Flagged ACE-side oddities") against the decomp: (1) Action-branch numAnims double-count (ACE L227) — retail sums `outHop + actionLink [+ returnHop]` (never the base cycle, never one MotionData twice, §5 Branch 3) → **ACE bug, do not copy**; (2) `change_cycle_speed` silent no-op when old≈0 & new≠0 — retail §2 has the SAME structure → **port verbatim including the gap** (it's retail); (3) `GetLinkData` 0xFFFF mask — ACE-only helper, not ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : anyModifierStopOk` (§9) — port verbatim; (5) `re_modify` snapshot double-removal — retail copy-ctor deep-copies the chains, the snapshot exists ONLY as the loop-termination bound (§6) → C# port: deep-copy MotionState, pop both, terminate on snapshot empty. | — | Documented adjudications in the pseudocode doc. |
| A5 | **DatReaderWriter `MotionData.Bitfield` availability**`is_allowed` needs bit1 ("substate-gated") and Branches 1/2/4 need bit0 ("clear modifiers on entry"). `Bitfield` string is present in the DLL (`chorizite.datreaderwriter/1.0.0`); confirm it's on `Types.MotionData` (not just GfxObj etc.) at Q2 time. | DLL strings grep | One-line test against the Humanoid table (a known bit-2-gated cycle, e.g. TurnRight). |
**Q0 cdb capture (one session serves all of R2):** bp
`CMotionTable::GetObjectSequence` / `get_link` / `StopSequenceMotion`,
`MotionTableManager::add_to_queue` / `remove_redundant_links` /
`truncate_animation_list` / `AnimationDone` / `CheckForCompletedMotions` with arg+ret
logging (pattern: `tools/cdb/l2g-observer.cdb`), user protocol: walk / run / shift-toggle
(fast path) / backward / turn-in-place / run-while-turning / sidestep / emote-while-running
/ attack / stop / stance change / rapid W-tapping (redundant-link collapse) / jump.
---
## 1. ITEMIZED GAPS — current vs retail (R2 scope)
Severity: **BLOCKER** = R2's conformance harness meaningless without it; **HIGH** =
visible animation wrongness / blocks R3; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| H1 | **No `CMotionTable::GetObjectSequence` — motion selection is a partial adapter hybrid.** Retail (0x00522860, decomp §5) is one dispatcher with 4 class branches (style `(int)id<0` / cycle `0x40000000` / action `0x10000000` / modifier `0x20000000`), plain-OR fallthrough, `is_allowed` gating, style-default double-hop link routing, modifier replay (`re_modify`), and outTicks. acdream's `SetCycle` implements only a subset of Branch 2 and its inventions (below) stand in for the rest. | decomp §5 @298636 | `AnimationSequencer.SetCycle` (:321-636), `PlayAction` (:791-888) | **BLOCKER** |
| H2 | **No `MotionState`** — no style/substate/substate_mod struct, no modifier stack (push-front `add_modifier_no_check` / dup-guarded `add_modifier` / `remove_modifier(node,prev)` / `clear_modifiers`), no action FIFO (`add_action` tail-append / `remove_action_head`). The adapter's `CurrentStyle/CurrentMotion/CurrentSpeedMod` is a 3-field flat approximation; modifiers/actions have no bookkeeping at all. | acclient.h:31081; decomp §13/§14 (all 9 members, 0x00525fd0-0x00526340) | `AnimationSequencer.cs` CurrentStyle/CurrentMotion/CurrentSpeedMod props; `RemoteMotionSink.cs:39-45` (per-UM axis duplicates) | **BLOCKER** |
| H3 | **No `MotionTableManager`** — no `pending_animations` DLList, no `animation_counter`, no `add_to_queue` (0x0051bfe0), no `remove_redundant_links` (0x0051bf20, 0xb0000000/0x70000000 block masks), no `truncate_animation_list` (0x0051bca0, zero-in-place + `remove_link_animations(ticks)`), no `AnimationDone` (0x0051bce0, counter-driven countdown-chain multi-pop + action-head pop + MotionDone), no `CheckForCompletedMotions`/`UseTime` (0x0051be00/0x0051bfd0, zero-tick sweep, success=1 hardcoded), no `initialize_state` (0x0051c030, `0x41000003` sentinel), no `HandleEnterWorld/HandleExitWorld` drains, no `PerformMovement` (0x0051c0b0, error codes 7/0x43/0). | decomp §11 (all 16 members) | nothing — the AnimationDoneSentinel the R1 core queues (CSequence.cs:413-418 → AnimationSequencer.cs:925) is consumed by NOBODY (gap map API table: "RemoteMotionSink/GameWindow don't consume AnimDone yet") | **BLOCKER** |
| H4 | **RemoteMotionSink's single-cycle pick** — axis collection (`_substate/_sidestep/_turn`), priority pick (fwd > sidestep > turn), `Commit()` one-SetCycle resolution. Retail: each funnel dispatch is its own `DoObjectMotion` → GetObjectSequence; turn/sidestep-while-moving resolve via `is_allowed` rejection of the (bitfield&2-gated) turn cycle → Branch 4 modifier combine (physics-only overlay), run cycle keeps playing; turn-in-place resolves via Branch 2 (cycle exists, allowed from the style default). Register row **AP-73** (retail-divergence-register.md:181). | decomp §3 (is_allowed) + §5 Branch 2/4 | `RemoteMotionSink.cs:141-216` (`Commit`), `:55-119` (`ApplyMotion` classify/collect) | **HIGH** — plan of record: "single-cycle pick DELETED — GetObjectSequence decides" |
| H5 | **HasCycle probe + Run→Walk→Ready missing-cycle fallback chain.** Existed because SetCycle clears the cyclic tail BEFORE knowing the cycle resolves ("torso on the ground"). Retail never has the problem: GetObjectSequence resolves ALL MotionData first, `clear_physics`/`remove_cyclic_anims` happen only inside a success path, missing cycles retry under `default_style` (Branch 2 second lookup) and otherwise `return 0` leaving the sequence untouched (PerformMovement → 0x43). | decomp §5 Branch 2 lines ~494-505 + return-0 tail | `RemoteMotionSink.cs:169-204`, `AnimationSequencer.HasCycle` (:281-295), GameWindow spawn fallbacks :3723-3825 | **HIGH** |
| H6 | **Fix B locomotion link-skip** (cyclic→cyclic transitions call `RemoveAllLinkAnimations` for the locomotion low-byte subset). Retail mechanism = `remove_redundant_links` on the PENDING QUEUE (tail-anchored backward scan for an earlier same-motion node; collapse via `truncate_animation_list` → zero ticks + `remove_link_animations(removedTicks)`; blocked by intervening non-zero 0xb0000000-class (cycle-tail scan) / 0x70000000-class (style-tail scan) nodes). The 2026-05-03 cdb trace in the Fix B comment block is the golden: cyclic→cyclic = `add_to_queue(45000005)` + `add_to_queue(44000007)`, truncate NOT firing — Fix B's outcome falls out of retail's structure (the link nodes the queue would truncate never get double-enqueued once GetObjectSequence owns link selection). | decomp §11 remove_redundant_links @290771 + truncate @290533 | `AnimationSequencer.cs:468-514` (Fix B block + `IsLocomotionCycleLowByte`) | **HIGH** — delete in favor of the queue mechanism |
| H7 | **Stop-anim fallback** (SetCycle low-byte remap WalkBackward→WalkForward etc. when linkData null). Retail: `adjust_motion` normalization happens UPSTREAM in CMotionInterp (already ported, D6.2a `0f099bb6`) so GetObjectSequence receives 0x05/0x0D/0x0F + signed speed; direction flips route via Branch 2's `link==null \|\| !same_sign` style-default double-hop (`get_link(...,styleDefault,1f)` + `get_link(styleDefault,1f,target,speed)`). | decomp §5 Branch 2 lines ~533-540 | `AnimationSequencer.cs:402-423` | **HIGH** |
| H8 | **Adapter fast path ≠ retail fast path.** Retail (§5 Branch 2): gate = `target==substate && same_sign(newSpeed, substate_mod) && has_anims()`, then `change_cycle_speed` (ratio, 0.0002f epsilon guards) + `subtract_motion(old)` + `combine_motion(new)` + commit substate_mod. acdream: gate keyed on Current* fields with its own 1e-4/1e-6 epsilons, and `MultiplyCyclicFramerate` folds a velocity/omega rescale composite in (the G13 stand-in, `AnimationSequencer.cs:673-686`). | decomp §5 lines ~513-522, §2 change_cycle_speed @298276 | `AnimationSequencer.cs:345-388`, `:673-686` | **HIGH** — G13/G17 retire here |
| H9 | **`add_motion` velocity gate (G17 adapter half).** Core `EnqueueMotionData` still gates `SetVelocity/SetOmega` on `MotionDataFlags.HasVelocity/HasOmega`; retail `add_motion` (0x005224b0) sets unconditionally (dat-silent MotionData carries zero → replace-with-zero), safe once modifiers route through `combine_motion` (Branch 4) instead of `add_motion`. | decomp §2 @298437 | `AnimationSequencer.cs:1044-1058` (documented "R2 stand-in") + `PlayAction:836-839,872-875` | MED — mechanical once Branch 4 exists |
| H10 | **PlayAction inventions**: (a) actions resolved via `GetLink` direct only — no default-substate out-and-back 4-layer chain (outHop@1.0 → actionLink@speed → returnHop@1.0 → base cycle@old substate_mod); (b) no `add_action` FIFO bookkeeping (so no completion pop, H3); (c) no `clear_physics`/`remove_cyclic_anims` rebuild — nodes are INSERTED before the cyclic tail + an invented cursor-jump (`AnimationSequencer.cs:877-887`); retail REBUILDS (base cycle restarts after the action); (d) modifier-class ids ENQUEUE ANIMS from the Modifiers dict — retail Branch 4 is **physics-only** (`combine_motion` velocity/omega; anims untouched) + `add_modifier` bookkeeping with the stop-then-re-add toggle. | decomp §5 Branch 3 (~591-647) / Branch 4 (~652-687) | `AnimationSequencer.PlayAction` :791-888 | **HIGH** |
| H11 | **No stop machinery**: `StopSequenceMotion` (0x00522fc0: cycle-stop = re-drive GetObjectSequence toward the style default with arg7=1; modifier-stop = `subtract_motion` + unlink), `StopObjectCompletely` (strip all modifiers then stop the substate), `SetDefaultState` (0x005230a0: full baseline reset, `clear_animations` not remove_cyclic). "Stop" today is whoever calls SetCycle(Ready 0x41000003). | decomp §7/§8/§9 | funnel `StopMotion``RemoteMotionSink.cs:121-131` (ObservedOmega zero only); GameWindow SetCycle(Ready) sites | **BLOCKER** for stop conformance |
| H12 | **The GetObjectSequence guard set**: entry guards (`style==0 \|\| substate==0` → 0), the modifier-class no-op fast path (`target==styleDefault && !stopCall && (substate & 0x20000000)` → 1), Branch 2's "leaving a modifier-class substate re-registers it as a modifier" (`add_modifier_no_check`) — none exist anywhere in acdream. | decomp §5 lines ~384-404, ~565-572 | — | HIGH (part of H1, called out because each is an easy silent omission) |
| H13 | **`re_modify` modifier replay across transitions** — every substate/style-changing branch replays the active modifier stack through recursive GetObjectSequence calls. This is DEV-9 / AP-73's "retail BLENDS modifiers over the substate cycle". | decomp §6 @298300 | — | **HIGH** — the plan of record's "(modifier blend — retires AP-73)" |
| H14 | **Style-change transitions absent** (Branch 1): stance switches never play the exit-link + style-to-style link + double-hop-via-default_style chain; RemoteMotionSink just stores `_style` and SetCycle keys the cycle dict with it. | decomp §5 Branch 1 (~411-487) | `RemoteMotionSink.cs:57-61`; `SetCycle` (style used only as key material) | MED-HIGH (visible on combat-stance changes) |
| H15 | **Spawn baseline**: retail `initialize_state``SetDefaultState` + queue `0x41000003` sentinel; enter/exit-world drain the queue (`AnimationDone(0)` loop; enter also `remove_all_link_animations`). acdream: RenderBootstrap 3-tier fallback + GameWindow SetCycle(Ready)/HasCycle chains. | decomp §11 initialize_state/HandleEnterWorld/HandleExitWorld | GameWindow :3723-3825; `RenderBootstrap.SequencerFactory` | MED |
| H16 | **MotionDone signal dead-ends.** Retail chain: CSequence AnimDone gate → AnimDoneHook singleton → `Hook_AnimDone``CPartArray::AnimationDone(1)``MotionTableManager::AnimationDone` → countdown pop → `CPhysicsObj::MotionDone(motion, success)` → (R3) CMotionInterp pending_motions. acdream stops at `AnimationDoneSentinel` in `_pendingHooks`; nothing counts it. | decomp §11 AnimationDone @290558 + gap map G5 | `AnimationSequencer.cs:918-931`; GameWindow :9882 drain ignores the sentinel type | **HIGH** — R2's named deliverable; see §4 below |
| H17 | **ObservedOmega side-write** — the sink seeds `RemoteMotion.ObservedOmega` from wire turns (`RemoteMotionSink.cs:95-101`) so remote rotation starts same-tick. Retail: turn omega enters the sequence via Branch 2 add_motion (turn cycle omega + adapter synthesis) or Branch 4 combine_motion, and body rotation comes from `CSequence.omega` through apply_physics per tick (R6 tick-order territory). | decomp §2 combine_motion; plan R6 | `RemoteMotionSink.cs:95-101`, `:127` | MED — carry the side-write into the replacement sink verbatim (register row), retire in R6 |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `get_link` two-branch sign-aware lookup + both fallbacks (pending A1 pin, which almost certainly CONFIRMS it) | 0x00522710 §4 (predicate per A1) | `AnimationSequencer.GetLink` :961-1005 — re-home into CMotionTable verbatim, do not rewrite |
| Cycle/link/modifier hash keying incl. 32-bit `<<16` truncation of full command words (`(0x8000003D<<16)==0x003D0000`) | §5 key math throughout | `SetCycle`/`HasCycle`/`PlayAction` key builds (`:426,293,824`) — carry the full-command-word convention into CMotionTable |
| `AnimData` speed scaling: framerate only (`AnimData::operator*`) | 0x00525d00 (r1 §25) | `BuildNode` :1014-1024 / `EnqueueMotionData` append path |
| `append_animation` first_cyclic-slides-to-tail + curr_anim seed (the structural base every add_motion depends on) | 0x00525510 (r1 §24) | `CSequence.AppendAnimation` (Motion/CSequence.cs:109-123) |
| Full remove-family + apricot + combine/subtract_physics + multiply_cyclic_animation_fr (framerates-only) | r1 §5-§14 | `CSequence` :163-253 — R2 calls these, zero changes |
| AnimDone LIST-STRUCTURE gate (head != first_cyclic) + IAnimHookQueue seam | 0x00525943 (r1 G5) | `CSequence.UpdateInternal` :407-422 — R2 only adds the CONSUMER |
| adjust_motion normalization upstream of dispatch (left→right / backward→forward + sign) | CMotionInterp (D6.2a port) | `MotionInterpreter` normalization (commit `0f099bb6`) — stays upstream; Q4 verifies single-site and deletes the SetCycle-head duplicate |
| Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / `DispatchInterpretedMotion` / `contact_allows_move` + 183-case conformance suite | S2a (7b0cbbda) | `MotionInterpreter.cs:1312-1420` — R2 sits BELOW it (replaces only the sink) |
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), outbound packers | plan "absorbed" list | untouched |
| K-fix18 `skipTransitionLink` instant-engage (jump/Falling) | none — invented; retires in **R3** jump family | `AnimationSequencer.cs:321,398,441-444`; GameWindow :4817-4831, :10224 — SURVIVES R2 at adapter, byte-identical, register row kept |
| Locomotion velocity synthesis (Walk 3.12 / Run 4.0 / Side 1.25 m·s⁻¹) + turn omega synthesis (π/2 rad·s⁻¹) | retail `get_state_velocity` (R3 scope) | `SetCycle` :539-635 — SURVIVES R2 (runs after PerformMovement), register rows kept |
| Retail slerp + BuildBlendedFrame render blend | FUN_005360d0 | `SlerpRetailClient` / `BuildBlendedFrame` — untouched |
| `0x41000003` == full-word MotionCommand.Ready == retail's stop/default sentinel | decomp §15 | adapter already uses 0x41000003 as the Ready id |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Tests:
`tests/AcDream.Core.Tests/Physics/Motion/` (pattern: R1's
`AnimSequenceNodeTests`/`CSequenceTests`). Every commit: build+test green, register
rows added/retired in-commit.
**Q0 — pseudocode + ambiguity pinning (docs only).**
`docs/research/2026-07-0x-motiontable-pseudocode.md` from r2-motiontable-decomp.md,
resolving A1A5 (§0 above) and adjudicating the 5 ACE oddities (A4). Run the ONE cdb
capture session (protocol in §0) — it feeds Q2/Q3/Q4 goldens.
Fixture source: **cdb** (live retail, l2g-observer.cdb pattern).
Deps: none (R1-P5/P6 committed is a precondition for Q4+, not Q0).
**Q1 — `MotionState` verbatim.** (closes H2)
`Motion/MotionState.cs`: `Style/Substate/SubstateMod(=1f)` + modifier STACK
(`AddModifierNoCheck` push-front 0x00525ff0; `AddModifier` dup-guard + `substate==id`
refuse 0x00526340; `RemoveModifier(node, prev)` 0x00526040; `ClearModifiers`
0x00526070) + action FIFO (`AddAction` tail-append 0x005260a0; `RemoveActionHead`
0x00526120; `ClearActions` 0x005260f0) + deep-copy ctor (A4-#5: chains copied, not
shared — re_modify's snapshot is a termination bound).
Tests first: stack-vs-FIFO discipline tables; AddModifier rejection (already-present /
equals-substate); copy independence (mutate original, snapshot unchanged).
Fixture source: **synthetic**.
Deps: Q0.
**Q2 — `CMotionTable` verbatim (pure selection logic, no queue).** (closes H1, H5-resolve-side, H7-routing, H8-core, H10-core, H11, H12, H13, H14; A1/A2/A5 land here)
`Motion/CMotionTable.cs` wrapping the DatReaderWriter `MotionTable` DBObj
(style_defaults/cycles/modifiers/links/default_style; keys = `(style<<16)|(id&0xFFFFFF)`
full-command-word convention). Free functions in the same file (retail free fns):
`add_motion` 0x005224b0 (**unconditional** SetVelocity/SetOmega — G17 core — +
AppendAnimation per speed-scaled AnimData), `combine_motion` 0x00522580 /
`subtract_motion` 0x00522600 (CombinePhysics/SubtractPhysics only — never anims),
`change_cycle_speed` 0x00522290 (0.0002f epsilons, verbatim incl. the A4-#2 gap),
`same_sign` 0x00522260. Members: `get_link` 0x00522710 (per A1 pin — expected: the
adapter's existing port re-homed), `is_allowed` 0x005226c0 (`Bitfield & 2` gate),
`GetObjectSequence` 0x00522860 (ALL of: entry guards; modifier-class no-op fast path;
Branch 1 style-change with exit-link + direct link + default_style double-hop + commit
+ re_modify + outTicks; Branch 2 with default_style cycle retry, is_allowed, re-speed
fast path (change_cycle_speed + subtract + combine), clear-modifiers bit0, direct-link
vs `!same_sign` double-hop, A2 signedSpeed, outgoing-modifier-substate re-registration,
re_modify, outTicks; Branch 3 action direct + 4-layer out-and-back, add_action,
outTicks; Branch 4 modifier physics-only combine + stop-then-re-add toggle),
`re_modify` 0x005222e0, `StopSequenceMotion` 0x00522fc0, `SetDefaultState` 0x005230a0
(`clear_animations` hard reset), `DoObjectMotion`/`StopObjectMotion`/
`StopObjectCompletely` 0x00523e90/ec0/ed0 (A4-#4 return semantics).
Tests first — the R2 conformance harness core:
(a) **dat fixtures** (Humanoid MotionTable via DatCollection, R1-P1 pattern):
Ready→Walk link+cycle chain shape; walk↔run re-speed fast path (framerates rescaled,
velocity = subtract-old+combine-new, NO list change); Walk→WalkBackward-normalized
(0x05, speed) sign-flip → style-default double-hop; stance change → Branch 1 chain;
emote-while-running → 4-layer out-and-back with base cycle re-added at OLD
substate_mod; turn-in-place → Branch 2 cycle; **run-while-turning → is_allowed rejects
the gated turn cycle → Branch 4 physics-only combine, run anims untouched** (the AP-73
mechanism test); modifier stop = subtract + unlink; StopObjectCompletely drains
modifiers then re-drives to style default; missing cycle → return 0, sequence
UNTOUCHED (H5); outTicks values per A3.
(b) **cdb goldens from Q0**: GetObjectSequence arg/ret + resolved-MotionData-key
conformance for the captured protocol.
Deps: Q1 (MotionState), R1 core.
**Q3 — `MotionTableManager` + pending_animations.** (closes H3, H15-core)
`Motion/MotionTableManager.cs` + `AnimNode {Motion, NumAnims}`. Fields per `Create`
0x0051bc50: table, state, animation_counter, pending queue (LinkedList<AnimNode>;
register row: managed list vs intrusive DLList — reuse the R1 AD-34 wording), plus an
`IMotionDoneSink` seam (stands in for `CPhysicsObj::MotionDone`; see §4). Methods:
`add_to_queue` 0x0051bfe0 (append + immediate `remove_redundant_links`),
`remove_redundant_links` 0x0051bf20 — **port retail's tail-anchored single scan**
(skip trailing zero-tick nodes; cycle-class-not-modifier tail: match earlier same-motion
non-zero node, blocked by intervening non-zero `0xb0000000`-class; style-class
(`(int)motion<0`) tail: exact match, blocked by non-zero `0x70000000`-class), NOT
ACE's restructured outer loop; `truncate_animation_list` 0x0051bca0 (zero `NumAnims`
in place — nodes stay queued — + `CSequence.RemoveLinkAnimations(removedTicks)`);
`AnimationDone(success)` 0x0051bce0 (counter += 1; pop every head with
`NumAnims <= counter`, action-class → `state.RemoveActionHead()`, fire
`sink.MotionDone(motion, success)`, counter = NumAnims; drained-list counter reset);
`CheckForCompletedMotions` 0x0051be00 (zero-tick heads only, success=1, no counter
touch) + `UseTime` alias; `initialize_state` 0x0051c030 (SetDefaultState + queue
`0x41000003`/outTicks + redundancy pass); `HandleEnterWorld` (remove_all_link_animations
+ drain via AnimationDone(0)) / `HandleExitWorld` (drain only); `PerformMovement`
0x0051c0b0 (InterpretedCommand → DoObjectMotion → add_to_queue(motion, outTicks);
StopInterpretedCommand → StopObjectMotion → add_to_queue(**0x41000003**, outTicks) on
success; StopCompletely → StopObjectCompletely + **unconditional**
add_to_queue(0x41000003); error codes 7 / 0x43 / 0; other MovementTypes untouched).
Tests first: countdown-chain tables (one AnimationDone completing MULTIPLE entries via
counter rollover; leftover-counter reset on drain); truncate blocked/allowed matrices
for both masks; zero-tick sweep vs counter sweep distinction; enter/exit-world drains
fire MotionDone(success=0) for every queued motion; rapid same-motion re-issue →
collapse (the Fix B replacement proof); the Q0 golden: cyclic→cyclic walk→run yields
`add_to_queue(0x45000005)` + `add_to_queue(0x44000007)` with truncate NOT firing
(2026-05-03 trace, quoted in the Fix B comment block).
Fixture source: **synthetic** + **cdb goldens from Q0**.
Deps: Q2.
**Q4 — adapter cutover: SetCycle/PlayAction rehosted on PerformMovement; DELETE Fix B + stop-anim fallback + fast-path composite + G17 gate; wire the queue drain.** (closes H6, H7, H8, H9, H10-adapter, H16-wiring; H12 guards live via Q2)
`AnimationSequencer` gains a `MotionTableManager` (constructed with the same
DatReaderWriter table + the CSequence core). `SetCycle(style, motion, speedMod,
skipTransitionLink)` becomes: dispatch style-class id then motion through
`PerformMovement(InterpretedCommand)` (MotionState now OWNS style/substate/
substate_mod; `CurrentStyle/CurrentMotion/CurrentSpeedMod` become read-only mirrors —
GameWindow :3723/4827/4915/4919 + sink ctor keep compiling). `PlayAction` → the same
dispatch (action/modifier ids hit Branch 3/4). K-fix18 preserved byte-identical at
adapter: `skipTransitionLink` → post-dispatch `ClearAnimations`-of-links exactly as
today (register row survives → R3). Velocity/omega synthesis blocks run AFTER dispatch,
unchanged (→ R3). **DELETE:** the SetCycle-head adjust_motion duplicate (verify every
caller pre-normalizes via MotionInterpreter; if any GameWindow raw call site doesn't,
normalize at the adapter boundary ONCE and note it), the adapter fast-path block
(:345-388), the stop-anim low-byte fallback (:402-423), Fix B +
`IsLocomotionCycleLowByte` (:468-514), `MultiplyCyclicFramerate`'s velocity-rescale
composite (:681-686 — change_cycle_speed+subtract/combine are now real; G13 row
retired), `EnqueueMotionData`'s HasVelocity/HasOmega gate (:1055-1058 — add_motion
unconditional; G17 row retired), PlayAction's insert-before-tail + cursor-jump +
modifier-anim-enqueue (:836-887). **Queue drain wiring (same commit — the queue must
not grow unbounded):** GameWindow anim tick (:9876-9890) counts drained
`AnimationDoneSentinel` instances → `manager.AnimationDone(true)` per sentinel, and
calls `manager.UseTime()` once per tick (zero-tick completions: stop-with-no-link,
fast-path re-speed outTicks=0). `IMotionDoneSink` bound to a diagnostic recorder
(ACDREAM_DUMP_MOTION line) — consumed for real in R3 (register row: MotionDone
observed-not-consumed, retire R3).
Tests first: FULL existing suite green (parity bar) + pre-cutover recorded
SetCycle-sequence traces (captured BEFORE this commit) replayed → same selected
cycle/link identities + same hook stream, with EXPECTED-DIFF annotations documented
per case (known intentional changes: action overlays now rebuild → base cycle restarts;
direction flips route the double-hop) + #61 boundary-flash re-check under the new link
path. Empty-MotionTable tier: PerformMovement returns 7, sequencer stays do-nothing
(RenderBootstrap invariant, r1 API table).
Fixture source: **pre-cutover recorded adapter traces (synthetic harness)** + suite.
Deps: Q2+Q3 (+ R1-P5/P6 committed).
**Q5 — RemoteMotionSink DELETED; funnel dispatches straight into PerformMovement; spawn/world lifecycle; AP-73 retired.** (closes H4, H5-callers, H11-callers, H15, H17-carry)
Replace `RemoteMotionSink` with a thin Core `IInterpretedMotionSink` implementation
(`Motion/MotionTableDispatchSink.cs` or direct on the entity's manager):
`ApplyMotion(motion, speed)``PerformMovement(InterpretedCommand{motion, speed})`;
`StopMotion(motion)``PerformMovement(StopInterpretedCommand{motion, 1f})`. No axis
collection, no priority pick, no Commit, no HasCycle probe, no Run→Walk→Ready chain —
GetObjectSequence + is_allowed decide (H4/H5). The ObservedOmega turn seed (H17) moves
verbatim into the new sink (register row updated: retire in R6 when apply_physics
drives remote rotation). GameWindow: `RemoteMotionSink` ctor sites (:4643-4646) swap
to the new sink; spawn/fallback SetCycle(Ready)/HasCycle chains (:3723-3825) →
`manager.initialize_state`; teleport/despawn/enter-world → HandleEnterWorld/
HandleExitWorld. Delete `AnimationSequencer.HasCycle` if caller-free after this (else
keep as diagnostic, note it).
Tests first: S2a 183-case funnel conformance suite green with sink assertions
re-targeted (dispatch order is funnel-owned and unchanged; assertions move from
"collected axes" to "PerformMovement call sequence"); MotionInterpreterFunnelTests
green; live-protocol smoke via ACDREAM_DUMP_MOTION + ACDREAM_REMOTE_VEL_DIAG
(walk/run/toggle/backward/turn/circle/sidestep/emote/attack/stop, player+NPC+monster);
**ONE user visual pass** (walk↔run stride continuity, turn-while-running legs, emote
overlay, stop settle — the stage acceptance).
Registers: **AP-73 DELETED in this commit**; rows added for ObservedOmega side-write
(if not already covered) and any adapter boundary normalization from Q4.
Deps: Q4.
**Q6 — register sweep + roadmap + digest (docs/cleanup only).**
Grep-sweep dead code (IsLocomotionCycleLowByte remnants, HasCycle callers, fallback
chains, SCFAST/SCFULL diag re-anchoring); reconcile every touched register row
(retired: AP-73, Fix B, stop-anim fallback, G13, G17; surviving with rows: K-fix18→R3,
velocity/omega synthesis→R3, ObservedOmega seed→R6, MotionDone-unconsumed→R3,
managed-LinkedList-vs-DLList); roadmap stage table (R2 shipped); memory digest note
(animation sequencer deep-dive cross-link: pending_animations gap CLOSED).
Deps: Q5.
Parallelization note: Q1 and the Q0 cdb capture are independent; Q2/Q3 are
sequential-coupled (Q3 calls Q2); Q4/Q5 each touch GameWindow — do NOT fan out
(feedback_dont_parallelize_coupled_plan_slices).
---
## 4. MotionDone → S2-funnel pending_motions (the R3 boundary contract)
What R2 ships and where R3 plugs in:
```
CSequence.UpdateInternal [R1, shipped]
└─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook
└─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5]
GameWindow anim tick (:9876-9890) [R2-Q4 wiring]
├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true)
│ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0
│ → CPartArray::AnimationDone(1) — one call per queued hook]
└─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions()
[retail call sites 0x00517d57/0x00517d67; drains zero-tick entries:
stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes]
MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3]
├─ action-class head (0x10000000) → MotionState.RemoveActionHead() [R2-OWNED —
│ the action FIFO pop lives HERE, not in R3]
└─ IMotionDoneSink.MotionDone(uint motion, bool success) [R2 seam]
R2: diagnostic recorder only (register row: unconsumed until R3)
R3: MotionInterpreter.MotionDone — pops CMotionInterp.pending_motions,
action-class → RemoveAction from raw+interpreted state, recomputes
IsAnimating (ACE MotionInterp.cs:210-231; MovementManager.MotionDone
relay). The funnel's own note at MotionInterpreter.cs:1395 marks the
attachment point.
```
Two structural facts R3 must respect (from r2-ace-motiontable's headline finding,
decomp-confirmed): **MotionTableManager is UPSTREAM of CMotionInterp in the
completion chain, never a peer** — retail keeps TWO pending trackers
(`MotionTableManager.pending_animations` under CPartArray vs
`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone`
feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of
both the sentinel drain and UseTime is provisional until **R6** installs retail's
`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/
CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point
as the G6 seam, unchanged.
Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from
Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes
true. R3's jump/HitGround logic keys off this flag — getting it wrong is invisible
until R3.

View file

@ -0,0 +1,277 @@
# R3-W0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r3-motioninterp-decomp.md` (line-anchored); the port
plan with the full ambiguity table is `r3-port-plan.md` §0. This note records the
PINNED resolutions the W1W6 ports code against. Every pin below was produced by an
independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(line numbers cited are that file's) and A1/A3 additionally survived an adversarial
refutation pass (independent Ghidra MCP decompiles + vtable-dump slot resolution).
**No pin was refuted; none blocks on cdb.**
## Pinned
- **A1 — `motion_allows_jump` (0x005279e0) polarity: PINNED as a BLOCKLIST.
`0` = jump allowed (pass), `0x48` = jump BLOCKED** (a
"YouCantJumpFromThisPosition"-class error code). The extraction doc's original
§3a "whitelist / 0x48 = allowed" note was inverted (now corrected in-place, same
commit). Confidence: **high** (adversarially verified; refutation failed).
Definitive blocklist (port as **literal uint ranges**, NOT enum-ordinal ranges):
| Motion id (literal uint) | MotionCommand names | Returns |
|---|---|---|
| `[0x1000006f, 0x10000078]` | MagicPowerUp01..MagicPowerUp10 | `0x48` BLOCK |
| `[0x10000128, 0x10000131]` | TripleThrustLow..MagicPowerUp07Purple | `0x48` BLOCK |
| `0x40000008` (exact) | **Fallen** | `0x48` BLOCK |
| `[0x40000016, 0x40000018]` | Reload, Unload, Pickup | `0x48` BLOCK |
| `[0x4000001e, 0x40000039]` | AimLevel..MagicPray | `0x48` BLOCK |
| `[0x41000012, 0x41000014]` | Crouch, Sitting, Sleeping | `0x48` BLOCK |
| everything else — incl. **Falling `0x40000015`**, Ready `0x41000003`, Dead `0x40000011`, all turn/sidestep ids (`0x6500000d` TurnRight etc.) | — | `0` PASS |
Evidence (all boundaries re-derived from the verbatim body, raw 304908304931):
`005279e9 if (arg2 > 0x40000018) { 00527a24 if (arg2 > 0x41000014) return 0;
00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)))
return 0; } 005279e9 else if (arg2 < 0x40000016) { 005279f7 if (arg2 > 0x10000131)
{ 00527a18 if (arg2 != 0x40000008) 00527a1c return 0; } 005279f7 else if ((arg2 <
0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) 00527a10 return 0; }
00527a40 return 0x48;` — the middle band `[0x40000016..0x40000018]` satisfies
neither outer branch and falls straight to `return 0x48`. Polarity sealed by four
independent callers plus the top of the chain:
1. `jump_is_allowed` 0x005282b0 (raw 305539305542): `eax_7 =
motion_allows_jump(this, this->interpreted_state.forward_command); if (eax_7
!= 0) return eax_7;` — nonzero returned verbatim as the failure code; all
success paths return `eax_7 == 0`.
2. `DoInterpretedMotion` 0x00528360 (raw 305597305607): params bit `0x20000`
(disable_jump_during_link) FORCES `eax_5 = 0x48` — a jump-*disable* bit
forcing the value only makes sense if `0x48` = block; `eax_5` is stashed as
the queue node's `jump_error_code` (add_to_queue arg4 → node +0xc, raw
305044), which `jump_is_allowed`'s head peek returns as an error when nonzero.
3. `CMotionInterp::jump` 0x00528780 (raw 305801305812): `result =
jump_is_allowed(...); if (result != 0) { standing_longjump = 0; return
result; }` — the jump only executes on the zero path.
4. Sibling convention: `jump_charge_is_allowed` (raw 304940304948) and
`charge_jump` (raw 305453305466) both `return 0` on pass / `0x48`/`0x49` on
block; `charge_jump`'s success path arms `standing_longjump` then `return 0`.
**Falling vs Fallen: retail blocks Fallen (0x40000008) and PASSES Falling
(0x40000015). ACE mis-transcribed** — ACE MotionInterp.cs:770-779's exact-id
term is `substate == (uint)MotionCommand.Falling` and Fallen appears nowhere in
its motion_allows_jump: the exact inverse of retail on those two ids. ACE's own
jump_charge_is_allowed (MotionInterp.cs:736) uses Fallen, matching retail's
0x40000008 in both charge gates — the Falling term is a one-off ACE transposition
slip, not a deliberate change. Falling still can't yield mid-air jumps in retail:
`jump_is_allowed`'s airborne gate (raw 305559305570) independently returns
`0x24` unless transient_state has Contact(1)+OnWalkable(2). **Port: block Fallen,
pass Falling.**
- **A2 — `jump_is_allowed` pending-head peek: PINNED — the peek fires whenever the
queue is non-empty; there is NO `Count > 1` gate.** Raw 305524305556
(0x005282b0): `if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
return 0x47;` THEN `class LListData* head_ = this->pending_motions.head_; ...
if (head_ != 0) eax_6 = *(int32_t*)((char*)head_ + 0xc); if ((head_ == 0 ||
eax_6 == 0)) { eax_6 = jump_charge_is_allowed(this); ... } return eax_6;` — if
the head exists AND its `jump_error_code` (+0xc) is nonzero, the whole
charge/motion/stamina chain is skipped and the peeked code returned. Ordering
note (both decompilers agree): **IsFullyConstrained (→0x47) is checked BEFORE
the peek** — port in that order. ACE L752-753's `PendingMotions.Count > 1 &&`
has no retail counterpart; do not copy. ACE L746's `WeenieObj == null &&
!WeenieObj.IsCreature()` NPE-typo also confirmed wrong — retail (raw
305517305520) is `if (weenie_obj != 0) eax_2 = weenie_obj->vtable->IsCreature();
if ((weenie_obj != 0 && eax_2 == 0))` → non-creature-with-weenie skips the ground
check. Confidence: **high**.
- **A3 — dual-dispatch gate: PINNED to `IsThePlayer` (vtable slot +0x14), NOT
`IsCreature` (+0x2c), in all four functions.** Confidence: **high**
(adversarially verified: independent Ghidra decompile shows raw offset +0x14 at
all four sites vs +0x2c in HitGround; the ACCWeenieObject vftable dump at
0x007e3ea0, raw 10351011035135, binds +0x14 → `ACCWeenieObject::IsThePlayer`
0x0058C3D0 and +0x2c → `IsCreature` 0x0058C3F0).
| Function | Address @ raw line | Weenie tested | Predicate → raw path | Dispatch args | Entry gate |
|---|---|---|---|---|---|
| `apply_current_movement` | 0x00528870 @305838 | `this->weenie_obj` | `(weenie==0 \|\| IsThePlayer()) && movement_is_autonomous()` | `(arg2, arg3)` passthrough | `physics_obj != 0 && initted != 0` |
| `ReportExhaustion` | 0x005288d0 @305861 | `this->weenie_obj` | same | `(0, 0)` | same |
| `SetWeenieObject` | 0x00528920 @305884 | **incoming `arg2`** (stored to `this->weenie_obj` first) | same | `(1, 0)` | same |
| `SetPhysicsObject` | 0x00528970 @305911 | `this->weenie_obj` | same | `(1, 0)` | `arg2 != 0 && initted != 0` (after `this->physics_obj = arg2`) |
Predicate true → `apply_raw_movement(a, b)`; false → `apply_interpreted_movement(a, b)`.
Verbatim (apply_current_movement, raw 305846305855): `if (weenie_obj != 0)
eax_2 = weenie_obj->vtable->IsThePlayer(); if (((weenie_obj == 0 || eax_2 != 0)
&& CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) {
apply_raw_movement(this, arg2, arg3); return; } apply_interpreted_movement(this,
arg2, arg3);`. Semantics: `IsThePlayer` (raw 406494406500) = `this->id ==
SmartBox::smartbox->player_id` (neg/sbb idiom); `movement_is_autonomous` (raw
276443276447) = `return this->last_move_was_autonomous;`. Anti-artifact proof:
`HitGround` (raw 306005) and `LeaveGround` (raw 306031), ~0x250 bytes away in
the same decomp region, call `IsCreature()` — BN distinguishes the two slots
locally, ruling out a slot-shift mislabel. **ACE MotionInterp.cs:430-438's
`IsCreature` predicate is a genuine server-side divergence — do NOT copy** (a
remote player is a creature but not the player; the wrong gate sends remotes
down `apply_raw_movement` against an empty raw state).
Watchouts: (a) a FIFTH IsThePlayer site exists in `move_to_interpreted_state`
(raw 305977): per queued action node, `if ((weenie_obj->vtable->IsThePlayer()
== 0 || node->autonomous == 0))` → execute — i.e. server-echoed autonomous
actions are SKIPPED for the local player (local-echo suppression). (b) the BASE
`CWeenieObject::vftable` data dump at 0x007e3df4 (raw 10350611035094) is
misaligned (BN data-label overlap artifact) — only the derived ACC table at
0x007e3ea0 is trustworthy. (c) the PDB also has a distinct
`ACCWeenieObject::IsPlayer` (slot +0x10, 0x0058C890) — never conflate the three
`Is*Player*` virtuals.
- **A4 — MovementParameters bit numbering: PINNED via the retail PDB's own
bitfield struct, acclient.h:31423-31443** — bit-for-bit identical to ACE's
`MovementParamFlags`. Confidence: **high**. Absolute masks:
| Mask | Flag | Mask | Flag |
|---|---|---|---|
| `0x1` | can_walk | `0x400` | use_spheres |
| `0x2` | can_run | `0x800` | set_hold_key |
| `0x4` | can_sidestep | `0x1000` | autonomous |
| `0x8` | can_walk_backwards | `0x2000` | modify_raw_state |
| `0x10` | can_charge | `0x4000` | modify_interpreted_state |
| `0x20` | fail_walk | `0x8000` | cancel_moveto |
| `0x40` | use_final_heading | `0x10000` | stop_completely |
| `0x80` | sticky | `0x20000` | disable_jump_during_link |
| `0x100` | move_away | | |
| `0x200` | move_towards | | |
Ctor default (raw 300510300534, 0x00524380): `(bitfield & 0xfffdee0f) |
0x1ee0f` → **0x1EE0F** sets {can_walk, can_run, can_sidestep,
can_walk_backwards, move_towards, use_spheres, set_hold_key, modify_raw_state,
modify_interpreted_state, cancel_moveto, stop_completely}; clears {can_charge,
fail_walk, use_final_heading, sticky, move_away, autonomous,
disable_jump_during_link}. Runtime reads all consistent: DoMotion @306183
byte1-sign = `0x8000` cancel_moveto → interrupt; @306188 byte1&8 = `0x800`
set_hold_key → `SetHoldKey(hold_key_to_apply, ((__inner0 >> 0xf) & 1))` — note
**SetHoldKey's second arg is the cancel_moveto bit**, not a constant; @306213
byte1&0x20 = `0x2000` modify_raw_state → RawMotionState::ApplyMotion mirror.
DoInterpretedMotion @305597 full-dword `& 0x20000` = disable_jump_during_link →
jump_error_code forced `0x48`; @305609/305617 byte1&0x40 = `0x4000`
modify_interpreted_state. StopMotion @305684 sign → interrupt; @305705
byte1&0x20 → RawState.RemoveMotion.
**ACE-divergence traps for W1:** ACE's MovementParameters ctor sets
`CanCharge = true` (MovementParameters.cs:58) — retail's default has `0x10`
CLEAR; port `can_charge = false`. ACE also changed Default_WalkRunThreshold to
1.0 (L50) vs retail's **15.0** (@300519). Retail's ctor caches the computed
default in a static (`normal_bitfield`, @300523300533) whose AND-mask preserves
uninitialized bits 1831 on first call — harmless (only bits 017 defined); port
as a plain 0x1EE0F default.
- **A5 — `get_jump_v_z` (0x00527aa0): PINNED to ACE's shape; the BN text is
x87-flag garbled exactly as suspected** (raw 304953304984 returns FCOM flag
synthesis on two paths — unreadable either way; the Ghidra decompile is clean
and adjudicates). Behavior: `jump_extent < 0.000199999995f`**0.0f**; else
clamp extent to max **1.0**; `weenie_obj == NULL`**10.0f** (`___real_41200000`);
`InqJumpVelocity(extent, &out)` false → **0.0f**; true → out. Matches ACE
L634-652. The epsilon literal is verbatim in the raw @304959: `long double
temp0 = ((long double)0.000199999995f);`. **acdream's 0.001
(MotionInterpreter.cs:278 JumpExtentEpsilon) must become 0.000199999995f.**
Confidence: **high**.
- **A6 — `get_leave_ground_velocity` (0x005280c0) zero-fallback matrix direction:
PINNED as GLOBAL→LOCAL** — decisively, by index-pattern match against Frame's
own transform pair. The fallback (raw 305434305440) computes `out.i =
fl2gv[3i+0]*v.x + fl2gv[3i+1]*v.y + fl2gv[3i+2]*v.z` — the IDENTICAL row-linear
pattern as `Frame::globaltolocalvec` 0x00452550 (raw 9146691471), while
`Frame::localtoglobalvec` 0x004524f0 (raw 9145291457) uses the transpose
(`fl2gv[0]*x + fl2gv[3]*y + fl2gv[6]*z`). BN's "m_fl2gv = local→global" reading
was name-misled; the applied math is global→local, matching ACE's
`GlobalToLocalVec(Velocity)`. **acdream's `Quaternion.Inverse(Orientation)`
transform (MotionInterpreter.cs:1024) IS global→local — keep.** Mechanics:
function is void (writes the caller's Vector3); body order =
`get_state_velocity(out)``out.z = get_jump_v_z()` → fallback fires ONLY when
`|x| AND |y| AND |z|` are ALL `< 0.000199999995f` (epsilon appears three times,
@305412/305420/305427, one per component) and then overwrites ALL THREE
components (including z, discarding the jump v_z) with
`globaltolocal(physics_obj->m_velocityVector)`. Epsilon must become 0.0002
(currently 0.001). Confidence: **high**.
- **A7 — `MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in
`CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp
doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed
node's motion id; the interp body reads NEITHER param in this build — **pop the
HEAD unconditionally, never match by motion id**. Keep the params for R5
signature parity. (Copied verbatim from the plan §0 adjudication — no new
research needed.)
- **A8 — `enter_default_state` (0x00528c80) does NOT clear `pending_motions`: it
APPENDS the `{0, 0x41000003, 0}` sentinel onto whatever queue exists.** Raw
306128306154: the body resets only raw_state + interpreted_state (fresh
default-constructed copies) and calls `CPhysicsObj::InitializeMotionTables`,
then `void* eax_2 = operator new(0x10); ... *(+8) = 0x41000003; *(+0xc) = 0;`
followed by the tail-append (`tail_ == 0 ? head_ = eax_2 : *tail_ = eax_2;
tail_ = eax_2`) — byte-identical in shape to `add_to_queue`'s append (raw
305047305057), just inlined — then `initted = 1; LeaveGround(this);`. No
drain/clear anywhere. ACE's `PendingMotions = new LinkedList` reset is the
divergence; pre-existing nodes survive in retail (W2 test asserts this). The
MovementManager lazy-create double-call (§6g) is genuine retail — do not "fix";
R3's direct-bind construction calls it once explicitly. Confidence: **high**.
- **A9 — `StopCompletely` (0x00527e40) jump-snapshot quirk: PINNED verbatim.**
Raw 305216305227 ordering: `interrupt_current_movement(physics_obj);` → `eax_2
= motion_allows_jump(this, this->interpreted_state.forward_command);` (**OLD**
forward_command, BEFORE the overwrite) → write raw_state
{forward_command=0x41000003, forward_speed=1f, sidestep_command=0,
turn_command=0} → same four writes to interpreted_state →
`StopCompletely_Internal(physics_obj)` → `add_to_queue(this, 0, 0x41000003,
eax_2)` → cell==0 → `RemoveLinkAnimations` → `return 0`. Entry: physics_obj==0
→ return 8. **Retail touches ONLY forward cmd/speed + sidestep/turn COMMANDS —
it does NOT write sidestep_speed or turn_speed** (acdream's 1.0 speed resets
are a divergence to remove per plan J9). Golden: the queued node's error code
reflects the pre-stop command. Confidence: **high**.
- **A10 — error-code semantics: PINNED with the definitive table** (exhaustive
`return <code>` sweep over raw 304908306277 + 300150300540; 19 return sites +
1 store site, all attributed). Codes are local-only (never on the wire) — safe
renumber in W1. Confidence: **high**.
| Code | Retail meaning | Sites (address @ raw line) |
|---|---|---|
| `8` | no physics_obj | StopCompletely @305214; DoInterpretedMotion @305579; StopInterpretedMotion @305639; StopMotion @305680; jump @305798; DoMotion @306165 |
| `0x24` | not grounded / no contact | jump_is_allowed @305570 (0x005282f0 — gravity-state creature without Contact+OnWalkable; **also covers its physics_obj==0 case**, see note); **DoInterpretedMotion @305622-305623** (action-class motion `arg2 & 0x10000000` blocked by contact_allows_move — second site, absent from the plan row) |
| `0x3f` | Crouch 0x41000012 in combat stance | DoMotion @306196 |
| `0x40` | Sitting 0x41000013 in combat stance | DoMotion @306199 |
| `0x41` | Sleeping 0x41000014 in combat stance | DoMotion @306202 |
| `0x42` | `motion & 0x2000000` outside NonCombat (0x8000003d) | DoMotion @306205 |
| `0x45` | action depth: GetNumActions ≥ 6 | DoMotion @306209 |
| `0x47` | general movement failure | jump_is_allowed @305525 (IsFullyConstrained); jump_is_allowed @305549+305556 (staged, returned when `JumpStaminaCost(arg2, arg3) == 0`); CMotionInterp::PerformMovement @306227 (`type-1 > 4`); MovementManager::PerformMovement @300201 (`type-1 > 8`) |
| `0x48` | jump BLOCKED by motion/position (**NOT a success sentinel** — the A1 polarity fix) | motion_allows_jump @304930; jump_charge_is_allowed @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 (same predicate); STORED (not returned) as node jump_error_code in DoInterpretedMotion @305605 on disable_jump_during_link |
| `0x49` | weenie `CanJump(jump_extent)` refused | jump_charge_is_allowed @304941; charge_jump @305454 |
Note: `jump_is_allowed` returns **0x24** (not 8) when `physics_obj == 0` (raw
structure 305512 + 305570: the `if (this->physics_obj != 0) {...}` wrapper falls
out to `return 0x24;`) — the "8 = no physics obj" convention holds everywhere
else but NOT there; ACE (L744-745) returns NoPhysicsObject there — small ACE
divergence, port retail. W1 renames acdream's WeenieError values to these
numerics (adds 0x3f/0x40/0x41/0x42/0x45; fixes 0x24/0x47/0x48 semantics — the
current enum returns 0x48 for airborne where retail returns 0x24).
## Adjacent findings (load-bearing for W-commits, discovered while pinning)
- **`move_to_interpreted_state` call-site garble (W-commit trap):** raw
305946305949 renders `int32_t esi_1 = -(eax_2); ...
apply_current_movement(this, 1, -((esi_1 - esi_1)));` — the second arg
algebraically collapses to constant 0, which is decompiler nonsense (sbb/neg
idiom). Intended semantics per ACE MotionInterp.cs:796-799: `allowJump =
(motion_allows_jump(OLD forward_command) == 0)` passed as
apply_current_movement's second arg. Port `allowJump = (eax_2 == 0)` — another
minor polarity trap in the A1 family.
- **`jump` (0x00528780) details for J7:** contains `interrupt_current_movement`
(@305800); on success stores `jump_extent = arg2` and calls
`set_on_walkable(obj, 0)`; clears `standing_longjump` ONLY on the failure path.
## W0 cdb capture (pending, non-blocking)
No pin above depends on it — all ten are textually pinned with adversarial
verification. One live session feeds the R3 goldens (W2/W3/W5/W6): bp
`CMotionInterp::MotionDone` / `add_to_queue` / `jump` / `jump_is_allowed` /
`jump_charge_is_allowed` / `charge_jump` / `motion_allows_jump` / `HitGround` /
`LeaveGround` / `StopCompletely` / `set_hold_run` / `SetHoldKey` /
`enter_default_state` with arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`);
user protocol per `r3-port-plan.md` §0 (idle / walk / run / shift-toggle /
tap-jump / full-charge jump / running jump / standing long-jump / jump-while-
crouched / jump-in-combat-stance (0x3f0x42 family) / walk off a ledge (the A6
momentum fallback) / land / emote-while-running / 7 rapid emotes (0x45 cap) /
logout drain). Nice-to-have live spot-checks it will also provide: A1 polarity
(log `motion_allows_jump` arg2+eax during walk/run/mid-fall/crouch jumps) and the
A2 head-peek short-circuit.

View file

@ -0,0 +1,987 @@
# R3 — ACE port map: MotionInterp + MovementManager + MotionDone chain
Sources (full-file reads, all line numbers are 1-indexed in the ACE source tree):
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` (836 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Managers/MovementManager.cs` (216 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionNode.cs` (21 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (252 lines, whole file read — AnimationDone/CheckForCompletedMotions, the actual MotionDone producer)
- `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` (targeted reads: L80-109 IsAnimating/IsMovingOrAnimating, L290-301 CheckForCompletedMotions, L890-903 MotionDone, L4235-4247 ShowPendingMotions/motions_pending)
- `references/ACE/Source/ACE.Server/Physics/PartArray.cs` (L60-77 CheckForCompletedMotions passthrough)
No named-retail cross-reference was performed in this pass (ACE-side mapping only, per R3 scope). ACE class is `MotionInterp` (note: **not** `CMotionInterp` — ACE dropped the `C` Hungarian prefix project-wide, this is a naming-convention divergence not a functional one).
---
## 1. Class fields (MotionInterp.cs L14-32)
```csharp
public bool Initted;
public WeenieObject WeenieObj;
public PhysicsObj PhysicsObj;
public RawMotionState RawState;
public InterpretedMotionState InterpretedState;
public float CurrentSpeedFactor;
public bool StandingLongJump;
public float JumpExtent;
public int ServerActionStamp;
public float MyRunRate;
public LinkedList<MotionNode> PendingMotions;
public const float BackwardsFactor = 6.4999998e-1f; // 0.65
public const float MaxSidestepAnimRate = 3.0f;
public const float RunAnimSpeed = 4.0f;
public const float RunTurnFactor = 1.5f;
public const float SidestepAnimSpeed = 1.25f;
public const float SidestepFactor = 0.5f;
public const float WalkAnimSpeed = 3.1199999f; // 3.12
```
Notes:
- `PendingMotions` is `LinkedList<MotionNode>`, not an array/ring buffer — ACE reimplemented retail's pending-motion queue as a doubly-linked list. `MotionNode` (see §2) carries `ContextID`, `Motion`, `JumpErrorCode` — this is the FIFO queue of in-flight interpreted motions waiting on animation completion.
- `CurrentSpeedFactor` is declared but the only read site in this file is `get_adjusted_max_speed()` (L629) — no write site anywhere in MotionInterp.cs. Likely set externally (WeenieObj or PhysicsObj) — not visible in this file.
- `JumpExtent` doubles as both "how hard is the current jump charging" (set in `jump()`, read in `charge_jump()`/`get_jump_v_z()`) and as a guard the WeenieObj layer checks via `CanJump(JumpExtent)`.
---
## 2. MotionNode (queue element) — MotionNode.cs, whole file
```csharp
public class MotionNode
{
public int ContextID;
public uint Motion;
public WeenieError JumpErrorCode;
public MotionNode() { }
public MotionNode(int contextID, uint motion, WeenieError jumpErrorCode)
{
ContextID = contextID;
Motion = motion;
JumpErrorCode = jumpErrorCode;
}
}
```
Trivial DTO. `JumpErrorCode` is the pre-computed "would a jump be legal right now, if the player pressed jump while this pending motion is still in flight" — computed once at `add_to_queue` time (see `motion_allows_jump` call sites in `DoInterpretedMotion`/`StopCompletely`), then consumed lazily by `jump_is_allowed()` (§7) via `PendingMotions.First.Value.JumpErrorCode` when `PendingMotions.Count > 1`.
---
## 3. add_to_queue / MotionDone / RemoveMotion (queue lifecycle)
### add_to_queue (L388-392)
```csharp
public void add_to_queue(int contextID, uint motion, WeenieError jumpErrorCode)
{
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
PhysicsObj.IsAnimating = true;
}
```
Simple append + set the `IsAnimating` flag unconditionally true (even if this is the first entry). Called from:
- `DoInterpretedMotion` (L85) — normal interpreted-motion success path, `jump_error_code` precomputed at L73-83.
- `StopCompletely` (L321) — with hardcoded `MotionCommand.Ready` and a `jump` precomputed at L307.
- `StopInterpretedMotion` (L348) — with hardcoded `MotionCommand.Ready` and `WeenieError.None`.
- `apply_interpreted_movement` (L495) — the turn-stop fallback branch, hardcoded `MotionCommand.Ready` / `WeenieError.None`.
### MotionDone (L210-234) — the dequeue-on-animation-complete handler
```csharp
public void MotionDone(bool success)
{
if (PhysicsObj == null) return;
var motionData = PendingMotions.First;
// null or != last in list?
if (motionData != null)
{
var pendingMotion = motionData.Value;
if ((pendingMotion.Motion & (uint)CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
motionData = PendingMotions.First;
if (motionData != null)
{
PendingMotions.Remove(motionData);
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
}
}
}
```
Note the ACE dev's own inline comment `// null or != last in list?` — flags this as a spot they weren't fully certain about vs. retail (a "did I get this right" breadcrumb). Two things happen if the head node exists:
1. If the head's Motion has the `Action` bit set (`CommandMask.Action`), it's treated as an "action" motion (e.g. attack/emote) that stuck the object to something — `unstick_from_object()` + pop the action off both InterpretedState and RawState action stacks.
2. **Re-fetches `PendingMotions.First` a second time** (redundant re-read — `motionData` unchanged since nothing between the two reads mutates the list) before removing it and recomputing `IsAnimating` as `Count > 0` (as opposed to `add_to_queue`'s unconditional `true`).
`success` parameter is accepted but **never read** in this method body — dead parameter as far as MotionInterp.MotionDone goes. (It IS read/propagated further down in `MotionTableManager.AnimationDone`/`CheckForCompletedMotions`, see §8 — but `MotionInterp.MotionDone` itself ignores it entirely.)
### RemoveMotion
Not defined in MotionInterp.cs — it's called on `InterpretedState`/`RawState` (`InterpretedState.RemoveMotion(motion)`, `RawState.RemoveMotion(motion)`), i.e. lives in `InterpretedMotionState`/`RawMotionState`/`MotionState` classes, NOT in MotionInterp. Call sites inside this file: L340, L351, L358, L498. Out of scope for this file-bounded pass (not in MotionInterp.cs or MovementManager.cs).
---
## 4. DoMotion / StopMotion / StopCompletely (top-level motion API)
### DoMotion (L112-158) — raw-command entry point
```csharp
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
var currentParams = new MovementParameters();
currentParams.CopySome(movementParams);
var currentMotion = motion;
if (movementParams.CancelMoveTo) PhysicsObj.cancel_moveto();
if (movementParams.SetHoldKey) SetHoldKey(movementParams.HoldKeyToApply, movementParams.CancelMoveTo);
adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply);
if (InterpretedState.CurrentStyle != (uint)MotionCommand.NonCombat)
{
switch (motion)
{
case (uint)MotionCommand.Crouch: return WeenieError.CantCrouchInCombat;
case (uint)MotionCommand.Sitting: return WeenieError.CantSitInCombat;
case (uint)MotionCommand.Sleeping: return WeenieError.CantLieDownInCombat;
}
if ((motion & (uint)CommandMask.ChatEmote) != 0) return WeenieError.CantChatEmoteInCombat;
}
if ((motion & (uint)CommandMask.Action) != 0)
{
if (InterpretedState.GetNumActions() >= 6) return WeenieError.TooManyActions;
}
var result = DoInterpretedMotion(currentMotion, currentParams);
if (result == WeenieError.None && movementParams.ModifyRawState)
RawState.ApplyMotion(motion, movementParams);
return result;
}
```
Key points:
- `switch (motion)` (combat-blocked motions) tests the **original** `motion` parameter, not `currentMotion` (post-`adjust_motion` mutation) — combat-block checks happen on the raw pre-adjustment command.
- Hardcoded action cap of 6 concurrent actions (`GetNumActions() >= 6`).
- `RawState.ApplyMotion` uses the **original** `motion`, not `currentMotion` — raw state records what was actually requested, interpreted state (via `DoInterpretedMotion`) gets the post-`adjust_motion` (walk→run promoted, backward-inverted, etc.) version.
### StopMotion (L367-386)
Mirror of DoMotion but for the "stop" side: `cancel_moveto` gate, `adjust_motion`, delegates to `StopInterpretedMotion`, then conditionally calls `RawState.RemoveMotion(motion)` (original motion, not adjusted) on success.
### StopCompletely (L301-327)
```csharp
public WeenieError StopCompletely()
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = (uint)MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
RawState.SideStepCommand = 0;
RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = (uint)MotionCommand.Ready;
InterpretedState.ForwardSpeed = 1.0f;
InterpretedState.SideStepCommand = 0;
InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal();
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
return WeenieError.None;
}
```
Hard-resets both Raw and Interpreted state (Forward/SideStep/Turn) to Ready/1.0/0/0 directly by field assignment — bypasses the normal `ApplyMotion`/`RemoveMotion` mutators entirely. `jump` (the eligibility precomputed BEFORE the reset, off the OLD `InterpretedState.ForwardCommand`) is stashed into the queued `Ready` node's `JumpErrorCode`. Delegates the physics-side reset to `PhysicsObj.StopCompletely_Internal()` (not in this file).
---
## 5. DoInterpretedMotion / StopInterpretedMotion (interpreted-command layer)
### DoInterpretedMotion (L51-110)
```csharp
public WeenieError DoInterpretedMotion(uint motion, MovementParameters movementParams)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
var result = WeenieError.None;
if (contact_allows_move(motion))
{
if (StandingLongJump && (motion == WalkForward || motion == RunForward || motion == SideStepRight))
{
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
}
else
{
if (motion == (uint)MotionCommand.Dead)
PhysicsObj.RemoveLinkAnimations();
result = PhysicsObj.DoInterpretedMotion(motion, movementParams);
if (result == WeenieError.None)
{
var jump_error_code = WeenieError.None;
if (movementParams.DisableJumpDuringLink)
jump_error_code = WeenieError.YouCantJumpFromThisPosition;
else
{
jump_error_code = motion_allows_jump(motion);
if (jump_error_code == WeenieError.None && (motion & (uint)CommandMask.Action) == 0)
jump_error_code = motion_allows_jump(InterpretedState.ForwardCommand);
}
add_to_queue(movementParams.ContextID, motion, jump_error_code);
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
}
}
}
else
{
if ((motion & (uint)CommandMask.Action) != 0)
result = WeenieError.YouCantJumpWhileInTheAir;
else
{
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
result = WeenieError.None;
}
}
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
return result;
}
```
Control-flow shape:
1. Gate on `contact_allows_move(motion)` (§7) — governs whether the object is grounded enough to accept this motion at all.
2. **StandingLongJump special-case**: if mid-standing-long-jump AND the incoming motion is one of {WalkForward, RunForward, SideStepRight}, the motion is applied to InterpretedState ONLY (no `PhysicsObj.DoInterpretedMotion` call, no queue entry) — the animation itself is suppressed while charging/airborne from a standing jump, but the *intent* state still updates so movement resumes correctly on landing.
3. Otherwise: `Dead` motion clears link animations first; then delegates the actual animation dispatch to `PhysicsObj.DoInterpretedMotion` (physics/animation-table layer, out of file scope); on success, computes the jump-error-code for THIS pending motion (double motion_allows_jump check: first against the incoming `motion` itself, then — only if the incoming motion isn't itself an Action AND passed — against the current `ForwardCommand`), queues it, and conditionally applies to InterpretedState.
4. If contact does NOT allow movement: Action-class motions fail outright with `YouCantJumpWhileInTheAir`; everything else silently updates InterpretedState (if requested) and returns success — i.e., non-action motions (turning, etc.) are allowed to update intent state even while airborne, just not animate/queue.
5. Unconditional tail: if `CurCell == null` (off the cell grid — e.g. despawned/uninitialized), strip link animations.
### StopInterpretedMotion (L329-365)
Structural mirror of DoInterpretedMotion for the "stop" direction: same `contact_allows_move` gate, same StandingLongJump special-case (calls `InterpretedState.RemoveMotion` instead of `ApplyMotion`), same delegate-to-PhysicsObj pattern (`PhysicsObj.StopInterpretedMotion`), same add_to_queue-with-Ready-on-success pattern, same CurCell==null tail cleanup. The "contact disallows" else-branch here has NO action-vs-non-action split (StopInterpretedMotion always just conditionally calls `RemoveMotion` and returns `WeenieError.None`) — asymmetric with DoInterpretedMotion's stricter "action motions error out while airborne" rule.
---
## 6. HitGround / LeaveGround (contact transition hooks)
### HitGround (L175-185)
```csharp
public void HitGround()
{
if (PhysicsObj == null) return;
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
PhysicsObj.RemoveLinkAnimations();
apply_current_movement(false, true);
}
```
Guarded to creature-only (or WeenieObj==null, i.e. non-weenie-backed physics objects) AND gravity-affected objects. Strips link animations and re-applies current movement intent with `cancelMoveTo=false, allowJump=true`.
### LeaveGround (L192-208)
```csharp
public void LeaveGround()
{
if (PhysicsObj == null) return;
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
var velocity = get_leave_ground_velocity();
PhysicsObj.set_local_velocity(velocity, true);
StandingLongJump = false;
JumpExtent = 0;
PhysicsObj.RemoveLinkAnimations();
apply_current_movement(false, true);
}
```
Same guard pattern as HitGround. Computes leave-ground velocity (§9), pushes it into physics as a LOCAL velocity (`set_local_velocity(velocity, true)` — second arg likely "isLocal"/"autonomous", not resolved in this file), clears the jump-charge state (`StandingLongJump=false`, `JumpExtent=0`), strips link anims, reapplies movement. **Called from `enter_default_state()` (L615)** as the terminal step of default-state setup — i.e. every newly-initialized MotionInterp starts as if it just left the ground.
MovementManager wrappers (MovementManager.cs):
- `HitGround()` (L66-73): calls `MotionInterpreter.HitGround()` THEN `MoveToManager.HitGround()` — both interpreters get the hit-ground signal, MotionInterp first.
- `LeaveGround()` (L104-110): calls `MotionInterpreter.LeaveGround()` only; has a dead commented-out line `// NoticeHandler::RecvNotice_PrevSpellSection` (retail-symbol trace left as a comment — not ported to ACE — a network-notice hook ACE apparently didn't implement here).
---
## 7. Jump family
### jump (L710-727)
```csharp
public WeenieError jump(float extent, int adjustStamina)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
PhysicsObj.cancel_moveto();
var result = jump_is_allowed(extent, adjustStamina);
if (result == WeenieError.None)
{
JumpExtent = extent;
PhysicsObj.set_on_walkable(false);
}
else
StandingLongJump = false;
return result;
}
```
Note: on success it sets `JumpExtent` and clears the walkable flag but does NOT itself call `LeaveGround()` or apply velocity — that must happen elsewhere (likely triggered by the physics tick detecting `on_walkable==false` + `JumpExtent>0`, out of file scope). On failure it clears `StandingLongJump` (defensive — aborts an in-progress standing-long-jump charge if the actual jump call fails).
### jump_is_allowed (L742-768)
```csharp
public WeenieError jump_is_allowed(float extent, int staminaCost)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
if (WeenieObj == null && !WeenieObj.IsCreature() || !PhysicsObj.State.HasFlag(PhysicsState.Gravity) ||
PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable))
{
if (PhysicsObj.IsFullyConstrained())
return WeenieError.GeneralMovementFailure;
if (PendingMotions.Count > 1 && PendingMotions.First.Value.JumpErrorCode != 0)
return PendingMotions.First.Value.JumpErrorCode;
var jumpError = jump_charge_is_allowed();
if (jumpError == WeenieError.None)
{
jumpError = motion_allows_jump(InterpretedState.ForwardCommand);
if (jumpError == WeenieError.None && WeenieObj != null && WeenieObj.JumpStaminaCost(extent, staminaCost) == 0)
jumpError = WeenieError.GeneralMovementFailure;
}
return jumpError;
}
return WeenieError.YouCantJumpWhileInTheAir;
}
```
**LIKELY BUG (ACE-side, flag for cross-check against named-retail):** `WeenieObj == null && !WeenieObj.IsCreature()` — if `WeenieObj` actually is null, `!WeenieObj.IsCreature()` would NPE; C#'s `&&` short-circuits left-to-right so `WeenieObj == null` being true still evaluates the right side... wait, no: `&&` short-circuits so if `WeenieObj == null` is `true`, C# does NOT evaluate `!WeenieObj.IsCreature()` — correct, no NPE. But semantically this condition is almost certainly meant to be `WeenieObj != null && !WeenieObj.IsCreature()` (matching the guard pattern used identically in HitGround/LeaveGround/apply_current_movement/adjust_motion — all of which use `WeenieObj != null && !WeenieObj.IsCreature()`). As written, `WeenieObj == null && !WeenieObj.IsCreature()` can **never be true** (if WeenieObj==null is true, the whole condition short-circuits false because `&&` needs the null-check false... actually WeenieObj==null must be TRUE for this branch, and if it's true the right operand isn't even evaluated, so `WeenieObj==null && [anything]` reduces to just needing `WeenieObj==null` — no, `A && B` requires BOTH true; if A is true B is still checked. If A is true (WeenieObj IS null) and B accesses `WeenieObj.IsCreature()` on a null WeenieObj, this WOULD NPE.** This is a genuine apparent typo vs. the established pattern elsewhere in the same file (should be `WeenieObj != null && !WeenieObj.IsCreature()`) — **flag as a divergence risk to verify against `docs/research/named-retail/` before porting this exact condition to acdream.**
- Entry condition: allowed to even check jump-legality if EITHER not-a-creature-weenie, OR not gravity-affected, OR (already in Contact+OnWalkable state).
- `IsFullyConstrained()` → GeneralMovementFailure.
- **Queue-lookahead check**: if there's more than one pending motion AND the head's precomputed `JumpErrorCode != 0`, return that cached error immediately (short-circuit — reuses the jump-eligibility computed back when that motion was queued, rather than recomputing).
- Otherwise chains `jump_charge_is_allowed()``motion_allows_jump(ForwardCommand)``WeenieObj.JumpStaminaCost(extent, staminaCost) == 0` (stamina check, external to this file) → `GeneralMovementFailure` if stamina call returns 0.
- If the outer gate fails, returns `YouCantJumpWhileInTheAir` (airborne + gravity + not a special-cased non-creature).
### jump_charge_is_allowed (L729-740)
```csharp
public WeenieError jump_charge_is_allowed()
{
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
return WeenieError.CantJumpLoadedDown;
var forward = InterpretedState.ForwardCommand;
if (forward == (uint)MotionCommand.Fallen || forward >= (uint)MotionCommand.Crouch && forward <= (uint)MotionCommand.Sleeping)
return WeenieError.YouCantJumpFromThisPosition;
return WeenieError.None;
}
```
`WeenieObj.CanJump(JumpExtent)` is presumably an encumbrance/burden check (name: "loaded down"). Blocks jump-charging while `Fallen` or in the [Crouch..Sleeping] MotionCommand range (a contiguous enum-range test — same idiom used throughout this file for "in one of these seated/prone states").
### charge_jump (L564-582)
```csharp
public int charge_jump()
{
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
return 0x49;
var forward = InterpretedState.ForwardCommand;
if (forward == (uint)MotionCommand.Falling || forward >= (uint)MotionCommand.Crouch && forward < (uint)MotionCommand.Sleeping)
return 0x48;
else
{
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && forward == (uint)MotionCommand.Ready &&
InterpretedState.SideStepCommand == 0 && InterpretedState.TurnCommand == 0)
{
StandingLongJump = true;
}
}
return 0;
}
```
Returns raw `int` error codes (`0x49`, `0x48`, `0`) rather than `WeenieError` enum — a leftover-looking retail-style raw-hresult-ish return convention (likely these hex values ARE `WeenieError` underlying ints but the method signature wasn't converted — worth checking `0x49`/`0x48` against the `WeenieError` enum values for `CantJumpLoadedDown`/`YouCantJumpFromThisPosition` equivalents). **Note the range-check here uses `Falling` (not `Fallen` as in `jump_charge_is_allowed`) and `< Sleeping` (exclusive) vs `jump_charge_is_allowed`'s `<= Sleeping` (inclusive)** — these two "similar" gating functions have subtly different boundary conditions; flag for retail cross-check, this looks like it could be an ACE transcription inconsistency OR an intentional retail distinction between "starting to charge a jump" vs "already mid-charge-and-issuing-the-actual-jump."
The actual `StandingLongJump = true` side-effect only fires when: grounded+walkable, current forward command is exactly `Ready` (standing still), AND no sidestep/turn in progress — i.e. this is the "player pressed-and-held jump while standing still" detector that arms the long-jump-charge special path used throughout DoInterpretedMotion/StopInterpretedMotion/apply_interpreted_movement.
### get_jump_v_z (L634-652)
```csharp
public float get_jump_v_z()
{
if (JumpExtent < PhysicsGlobals.EPSILON) return 0.0f;
var extent = JumpExtent;
if (extent > 1.0f) extent = 1.0f;
if (WeenieObj == null) return 10.0f;
float vz = extent;
if (WeenieObj.InqJumpVelocity(extent, out vz))
return vz;
return 0.0f;
}
```
Clamps `JumpExtent` to [something-above-EPSILON, 1.0], delegates the actual extent→velocity curve to `WeenieObj.InqJumpVelocity` (out of file scope — presumably reads jump skill). Fallback of `10.0f` if there's no WeenieObj at all (non-weenie physics object jumping at max power, e.g. test/editor objects). `float vz = extent;` is a dead initializer — immediately overwritten by the `out vz` param if `InqJumpVelocity` returns true, else the method returns `0.0f` on the false path (never returns the dead-initialized `extent` value).
### get_leave_ground_velocity (L654-663)
```csharp
public Vector3 get_leave_ground_velocity()
{
var velocity = get_state_velocity();
velocity.Z = get_jump_v_z();
if (Vec.IsZero(velocity))
velocity = PhysicsObj.Position.GlobalToLocalVec(PhysicsObj.Velocity);
return velocity;
}
```
Composes horizontal velocity from current interpreted-state motion (§9 `get_state_velocity`) with vertical velocity from the jump charge, UNLESS the composed vector is exactly zero — in which case it falls back to converting the physics object's actual (global) velocity into local space. This fallback matters for e.g. falling-off-a-ledge (no jump input, no WASD, but the object still has downward/lateral velocity from having walked off an edge) vs. a genuine standing-still jump.
---
## 8. HitGround/MotionDone chain — full call graph (cross-file, MotionTableManager.cs)
The actual "animation finished playing" signal originates in `MotionTableManager` (a SEPARATE class from `MotionInterp`, owned by `PartArray`, NOT by `MovementManager`):
```
MotionTableManager.AnimationDone(bool success) [L28-61]
MotionTableManager.CheckForCompletedMotions() [L63-85]
-> PhysicsObj.MotionDone(motionID, success) [PhysicsObj.cs L899-903]
-> MovementManager.MotionDone(motion, success) [MovementManager.cs L118-122]
-> MotionInterpreter.MotionDone(success) [MotionInterp.cs L210-234]
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) [parallel notify, weenie-layer]
```
Both `AnimationDone` and `CheckForCompletedMotions` in MotionTableManager independently walk `PendingAnimations` (a `LinkedList<AnimNode>`, the animation-table-layer's OWN pending queue — distinct from `MotionInterp.PendingMotions`) and call `PhysicsObj.MotionDone` once per completed entry, followed immediately by `WeenieObj.OnMotionDone` — i.e. **every completed motion fires two independent listeners**: the MotionInterp queue-pop (state-machine bookkeeping) and the WeenieObject notification (game-logic reaction, e.g. quest triggers, sound cues — out of scope here).
`CheckForCompletedMotions()` reachability:
```
MotionInterp.PerformMovement(mvs) [L260] -- called after EVERY movement dispatch (Do/Stop/StopCompletely)
-> PhysicsObj.CheckForCompletedMotions() [PhysicsObj.cs L296-300]
-> PartArray.CheckForCompletedMotions() [PartArray.cs L72-76]
-> MotionTableManager.CheckForCompletedMotions() [L63-85]
```
So every `MovementManager.PerformMovement``MotionInterp.PerformMovement` call (§10) ends with an immediate synchronous drain of any already-finished animations at the head of `PendingAnimations` — this is how a 0-frame-length animation (e.g. an instant `Ready` transition) gets its `MotionDone` fired same-tick rather than waiting for the next animation-tick callback.
`MotionTableManager.UseTime()` (L158-161) also calls `CheckForCompletedMotions()` — this is the periodic (per-tick, presumably driven by `MovementManager.UseTime()``MoveToManager.UseTime()`... **NOTE:** `MovementManager.UseTime()` (MovementManager.cs L176-179) only forwards to `MoveToManager.UseTime()`, NOT to `MotionInterpreter`/`MotionTableManager` — so `MotionTableManager.UseTime()`'s caller is NOT in this file pair; it must be driven directly by `PartArray`/`Sequence`'s own per-tick update, bypassing MovementManager entirely).
`AnimationDone` (L28-61) is the OTHER path into the same `PhysicsObj.MotionDone` call — driven by whatever animation-tick/callback system invokes it directly (not visible in these two files; likely `Sequence`/`AFrame` playback completion). Its loop differs from `CheckForCompletedMotions` in that it decrements a running `AnimationCounter` against each node's `NumAnims` rather than checking `NumAnims != 0` directly — i.e. `AnimationDone` is the incremental "one more anim-frame-group finished" tick, while `CheckForCompletedMotions` is the "resync/drain everything already at zero" batch pass.
---
## 9. apply_raw / apply_interpreted / apply_current_movement (state → animation dispatch)
### apply_current_movement (L430-438) — dispatcher
```csharp
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null || !Initted) return;
if (WeenieObj != null && !WeenieObj.IsCreature() || !PhysicsObj.movement_is_autonomous())
apply_interpreted_movement(cancelMoveTo, allowJump);
else
apply_raw_movement(cancelMoveTo, allowJump);
}
```
Routes to raw-vs-interpreted based on: non-creature-weenie OR not-autonomous-movement → interpreted path; creature AND autonomous movement → raw path. (`movement_is_autonomous()` presumably distinguishes server-driven/scripted motion from player-input-driven motion — out of file scope.) Requires `Initted == true` (set at the tail of `enter_default_state`, §11) — calls before init are no-ops.
### apply_raw_movement (L506-523)
```csharp
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null) return;
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand;
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
InterpretedState.SideStepSpeed = RawState.SideStepSpeed;
InterpretedState.TurnCommand = RawState.TurnCommand;
InterpretedState.TurnSpeed = RawState.TurnSpeed;
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
apply_interpreted_movement(cancelMoveTo, allowJump);
}
```
Copies all 7 raw-state fields verbatim into interpreted-state, THEN runs `adjust_motion` (§12) independently over each of the three command/speed pairs (Forward, SideStep, Turn) with their respective per-axis hold-keys, THEN falls through to `apply_interpreted_movement`. This is the "translate the low-level input intent into the higher-level interpreted/animation intent" step — walk→run promotion, backward-inversion, sidestep animation-rate scaling all happen here per-axis.
### apply_interpreted_movement (L440-504)
```csharp
public void apply_interpreted_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null) return;
var movementParams = new MovementParameters();
movementParams.SetHoldKey = false;
movementParams.ModifyInterpretedState = false;
movementParams.CancelMoveTo = cancelMoveTo;
movementParams.DisableJumpDuringLink = !allowJump;
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams);
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams);
if (InterpretedState.SideStepCommand != 0)
{
movementParams.Speed = InterpretedState.SideStepSpeed;
DoInterpretedMotion(InterpretedState.SideStepCommand, movementParams);
}
else
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
}
else
{
movementParams.Speed = 1.0f;
DoInterpretedMotion((uint)MotionCommand.Ready, movementParams);
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
}
}
else
{
movementParams.Speed = 1.0f;
DoInterpretedMotion((uint)MotionCommand.Falling, movementParams);
}
if (InterpretedState.TurnCommand != 0)
{
movementParams.Speed = InterpretedState.TurnSpeed;
DoInterpretedMotion(InterpretedState.TurnCommand, movementParams);
}
else
{
var result = PhysicsObj.StopInterpretedMotion((uint)MotionCommand.TurnRight, movementParams);
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None);
if (movementParams.ModifyInterpretedState)
InterpretedState.RemoveMotion((uint)MotionCommand.TurnRight);
}
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
}
}
```
This is the per-axis re-dispatch of the CURRENT interpreted state as fresh `DoInterpretedMotion`/`StopInterpretedMotion` calls (used both after raw→interpreted translation, and directly whenever contact/gravity transitions need to re-trigger animation — e.g. HitGround/LeaveGround/SetPhysicsObject/SetWeenieObject/SetHoldKey/set_hold_run all funnel here via `apply_current_movement`). Sequence:
1. `CurrentStyle` motion (stance) always re-dispatched first.
2. `MyRunRate` cache updated from `ForwardSpeed` whenever currently running (side-channel — persists the "last known run speed" independent of whatever WeenieObj.InqRunRate later reports).
3. If contact allows movement: normal case re-dispatches Forward + (SideStep OR explicit SideStepRight-stop); StandingLongJump case instead forces Ready + explicit SideStepRight-stop (suppresses forward/sidestep animation while charging a standing jump, matching the earlier note in DoInterpretedMotion).
4. If contact does NOT allow movement: unconditionally dispatches `Falling` (Speed=1.0) regardless of what ForwardCommand was.
5. Turn is handled as its own independent branch — Turn re-dispatches like Forward/SideStep, but the "stop" path when TurnCommand==0 is inlined directly here (calls `PhysicsObj.StopInterpretedMotion` — the PHYSICS layer method, not `this.StopInterpretedMotion` — and duplicates the add_to_queue/RemoveMotion/RemoveLinkAnimations bookkeeping inline rather than delegating to `this.StopInterpretedMotion`). This inline duplication vs delegating is a structural oddity worth flagging — every other "stop with bookkeeping" path in this file goes through the member `StopInterpretedMotion` method, but this one open-codes it against `PhysicsObj.StopInterpretedMotion` directly.
### get_state_velocity (L678-700) — used by get_leave_ground_velocity (§7)
```csharp
public Vector3 get_state_velocity()
{
var velocity = Vector3.Zero;
if (InterpretedState.SideStepCommand == (uint)MotionCommand.SideStepRight)
velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
if (InterpretedState.ForwardCommand == (uint)MotionCommand.WalkForward)
velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed;
else if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed;
var rate = MyRunRate;
if (WeenieObj != null) WeenieObj.InqRunRate(ref rate);
var maxSpeed = RunAnimSpeed * rate;
if (velocity.Length() > maxSpeed)
{
velocity = Vector3.Normalize(velocity);
velocity *= maxSpeed;
}
return velocity;
}
```
X = lateral (sidestep) component, Y = forward component (local space — X/Y here are NOT world axes). Clamped to a max speed derived from `RunAnimSpeed * runRate` — note `WeenieObj.InqRunRate(ref rate)`'s return value is discarded here (unlike `get_adjusted_max_speed`/`get_max_speed` which check the bool return and fall back to `MyRunRate` only if it returns false) — `rate` is pre-seeded with `MyRunRate` and then `InqRunRate` is allowed to overwrite it unconditionally regardless of success/failure return.
---
## 10. PerformMovement (MotionInterp) vs PerformMovement (MovementManager)
### MotionInterp.PerformMovement (L236-262)
```csharp
public WeenieError PerformMovement(MovementStruct mvs)
{
var result = WeenieError.None;
switch (mvs.Type)
{
case MovementType.RawCommand: result = DoMotion(mvs.Motion, mvs.Params); break;
case MovementType.InterpretedCommand: result = DoInterpretedMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopRawCommand: result = StopMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopInterpretedCommand: result = StopInterpretedMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopCompletely: result = StopCompletely(); break;
default: return WeenieError.GeneralMovementFailure;
}
PhysicsObj.CheckForCompletedMotions();
return result;
}
```
Dispatch table over `MovementType`; unconditionally drains completed motions (§8) after every dispatched call (except the `default`/unknown-type early-return, which skips the drain).
### MovementManager.PerformMovement (L124-157)
```csharp
public WeenieError PerformMovement(MovementStruct mvs)
{
PhysicsObj.set_active(true);
switch (mvs.Type)
{
case MovementType.RawCommand:
case MovementType.InterpretedCommand:
case MovementType.StopRawCommand:
case MovementType.StopInterpretedCommand:
case MovementType.StopCompletely:
if (MotionInterpreter == null)
{
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
if (PhysicsObj != null) MotionInterpreter.enter_default_state();
}
return MotionInterpreter.PerformMovement(mvs);
case MovementType.MoveToObject:
case MovementType.MoveToPosition:
case MovementType.TurnToObject:
case MovementType.TurnToHeading:
if (MoveToManager == null)
MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj);
return MoveToManager.PerformMovement(mvs);
default:
return WeenieError.GeneralMovementFailure;
}
}
```
Outer layer: unconditionally activates the physics object (`set_active(true)`) BEFORE dispatch, lazy-constructs `MotionInterpreter` (with `enter_default_state()`) on first use for the motion-command group, lazy-constructs `MoveToManager` on first use for the moveto/turnto group. This is the true entry point most game-logic code calls — `MotionInterp.PerformMovement` is only reachable through this wrapper (or directly if something already holds a `MotionInterp` reference, e.g. via `get_minterp()`).
---
## 11. Lifecycle: enter_default_state / HandleExitWorld / HandleEnterWorld
### MotionInterp.enter_default_state (L604-616)
```csharp
public void enter_default_state()
{
RawState = new RawMotionState();
InterpretedState = new InterpretedMotionState();
PhysicsObj.InitializeMotionTables();
PendingMotions = new LinkedList<MotionNode>(); // ??
add_to_queue(0, (uint)MotionCommand.Ready, 0);
Initted = true;
LeaveGround();
}
```
Note the ACE dev's own `// ??` comment on the `PendingMotions` reset — another self-flagged uncertainty spot (worth checking retail decomp for whether PendingMotions is genuinely reset here or whether retail preserves/asserts-empty). Order: fresh Raw/Interpreted state objects → physics-layer motion table init → fresh pending-motion queue → seed the queue with one `Ready` motion (contextID=0, jumpErrorCode=`WeenieError.None`=0) → flip `Initted=true` → call `LeaveGround()` (§6) which itself is now unlocked since `Initted` gates nothing in LeaveGround directly, but LeaveGround calls `apply_current_movement` which DOES gate on `Initted`.
### MovementManager.EnterDefaultState (L38-46)
```csharp
public void EnterDefaultState()
{
if (PhysicsObj == null) return;
if (MotionInterpreter == null)
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
MotionInterpreter.enter_default_state();
}
```
Thin lazy-construct + delegate wrapper.
### HandleExitWorld — two versions
**MotionInterp.HandleExitWorld (L160-173):**
```csharp
public void HandleExitWorld()
{
foreach (var pendingMotion in PendingMotions)
{
if (PhysicsObj != null && (pendingMotion.Motion & (uint)CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
}
PendingMotions.Clear();
if (PhysicsObj != null) PhysicsObj.IsAnimating = false;
}
```
Iterates ALL pending motions (not just the head, unlike `MotionDone`) — for EVERY pending motion that has the Action bit set, unsticks + pops an action off both state stacks (this will over-pop if multiple pending Action motions exist simultaneously and InterpretedState/RawState only track a bounded action stack — worth checking `GetNumActions()`'s cap of 6 against how many action-removals this loop could trigger). Then clears the whole queue and force-sets `IsAnimating = false` directly (bypassing the `Count > 0` recompute that `MotionDone` uses).
**MovementManager.HandleExitWorld (L54-58):** thin delegate, `if (MotionInterpreter != null) MotionInterpreter.HandleExitWorld();` — no MoveToManager involvement (contrast with `HitGround` which drives both).
### MovementManager.HandleEnterWorld (L48-52)
```csharp
public void HandleEnterWorld()
{
//if (MotionInterpreter != null)
//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter);
}
```
Entirely commented out — dead/no-op in ACE. The commented reference to `NoticeHandler.RecvNotice_PrevSpellSelection` is a retail-symbol breadcrumb (spell-selection restore notice on world-enter) that ACE chose not to implement. **Flag for acdream: if named-retail decomp confirms this notice is meaningful (e.g. restoring a previously-selected spell/combat-style UI state on relog), this is a genuine ACE gap, not just dead weight — acdream may need to port it from decomp directly since ACE has nothing to copy.**
Also note: `MotionTableManager.HandleEnterWorld(Sequence sequence)` (MotionTableManager.cs L103-108) is a DIFFERENT, unrelated `HandleEnterWorld` on a different class — it drains `PendingAnimations` via repeated `AnimationDone(false)` calls and clears link animations on the sequence. Not reachable from `MovementManager.HandleEnterWorld` (which is a no-op) — must be invoked from elsewhere (PartArray or the weenie enter-world path, out of scope).
---
## 12. adjust_motion / apply_run_to_command (raw→interpreted transform helpers)
### adjust_motion (L394-428)
```csharp
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature())
return;
switch (motion)
{
case (uint)MotionCommand.RunForward:
return;
case (uint)MotionCommand.WalkBackwards:
motion = (uint)MotionCommand.WalkForward;
speed *= -BackwardsFactor; // -0.65
break;
case (uint)MotionCommand.TurnLeft:
motion = (uint)MotionCommand.TurnRight;
speed *= -1.0f;
break;
case (uint)MotionCommand.SideStepLeft:
motion = (uint)MotionCommand.SideStepRight;
speed *= -1.0f;
break;
}
if (motion == (uint)MotionCommand.SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) = 1.248
if (holdKey == HoldKey.Invalid)
holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run)
apply_run_to_command(ref motion, ref speed);
}
```
Canonicalizes "left/backward" variants into their "right/forward" counterparts with negated speed (single-animation-per-axis-direction retail convention: there's no separate WalkBackwards/TurnLeft/SideStepLeft animation state, just the canonical one played at negative speed). `WalkBackwards` gets an EXTRA `-BackwardsFactor` (0.65) scalar on top of the sign flip — backward walking is intentionally slower than forward. `SideStepRight` always gets rescaled by `SidestepFactor * (WalkAnimSpeed/SidestepAnimSpeed)` ≈ 1.248 regardless of hold-key, to convert a walk-speed-denominated input into the sidestep animation's own speed scale. `HoldKey.Invalid` falls back to whatever `RawState.CurrentHoldKey` currently is; if the resolved hold key is `Run`, defers to `apply_run_to_command` for the walk→run promotion.
### apply_run_to_command (L525-562)
```csharp
public void apply_run_to_command(ref uint motion, ref float speed)
{
var speedMod = 1.0f;
if (WeenieObj != null)
{
var runFactor = 0.0f;
if (WeenieObj.InqRunRate(ref runFactor))
speedMod = runFactor;
else
speedMod = MyRunRate;
}
switch (motion)
{
case (uint)MotionCommand.WalkForward:
if (speed > 0.0f)
motion = (uint)MotionCommand.RunForward;
speed *= speedMod;
break;
case (uint)MotionCommand.TurnRight:
speed *= RunTurnFactor; // 1.5
break;
case (uint)MotionCommand.SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed))
speed = speed > 0.0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
break;
}
}
```
`WalkForward` promotes to `RunForward` ONLY if `speed > 0.0f` — i.e. a negative-speed "walk forward" (which per `adjust_motion` is actually a canonicalized WalkBackwards) does NOT get promoted to running even while the Run hold-key is active; backward movement stays at walk-animation regardless of hold-key. `TurnRight` while running gets a flat 1.5x speed multiplier (turns faster while running). `SideStepRight` gets the run-rate multiplier applied AND is then hard-clamped to ±`MaxSidestepAnimRate` (3.0) — this clamp exists ONLY in the run-path, not in the base `adjust_motion` sidestep scaling, meaning sidestep-while-walking is unclamped but sidestep-while-running is capped.
---
## 13. contact_allows_move / motion_allows_jump / is_standing_still (gating predicates)
### contact_allows_move (L584-602)
```csharp
public bool contact_allows_move(uint motion)
{
if (PhysicsObj == null) return false;
if (motion == (uint)MotionCommand.Dead || motion == (uint)MotionCommand.Falling ||
motion >= (uint)MotionCommand.TurnRight && motion <= (uint)MotionCommand.TurnLeft)
return true;
if (WeenieObj != null && !WeenieObj.IsCreature())
return true;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity))
return true;
if (!PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
return false;
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable))
return true;
return false;
}
```
Always-allowed motion classes: `Dead`, `Falling`, and the [TurnRight..TurnLeft] enum range (turning is always allowed regardless of contact — matches `apply_interpreted_movement`'s independent Turn-branch handling). Non-creature weenies bypass all contact gating. Non-gravity objects bypass gating. Otherwise: requires BOTH `Contact` AND `OnWalkable` transient flags to allow movement; `Contact` without `OnWalkable` (e.g. touching a wall/ceiling, not a floor) explicitly disallows.
### motion_allows_jump (L770-779)
```csharp
public WeenieError motion_allows_jump(uint substate)
{
if (substate >= (uint)MotionCommand.Reload && substate <= (uint)MotionCommand.Pickup ||
substate >= (uint)MotionCommand.TripleThrustLow && substate <= (uint)MotionCommand.MagicPowerUp07Purple ||
substate >= (uint)MotionCommand.MagicPowerUp01 && substate <= (uint)MotionCommand.MagicPowerUp10 ||
substate >= (uint)MotionCommand.Crouch && substate <= (uint)MotionCommand.Sleeping ||
substate >= (uint)MotionCommand.AimLevel && substate <= (uint)MotionCommand.MagicPray ||
substate == (uint)MotionCommand.Falling)
{
return WeenieError.YouCantJumpFromThisPosition;
}
return WeenieError.None;
}
```
Five contiguous MotionCommand enum ranges + one exact match, ALL block jumping: [Reload..Pickup], [TripleThrustLow..MagicPowerUp07Purple], [MagicPowerUp01..MagicPowerUp10], [Crouch..Sleeping], [AimLevel..MagicPray], and exactly `Falling`. This depends entirely on the ORDER of values in the `MotionCommand` enum matching retail's numeric ordering — **any acdream MotionCommand enum that doesn't preserve retail's exact ordinal layout for these ranges will silently break this range-check port.** Called from: `DoInterpretedMotion` (jump-error precompute, twice — against incoming motion AND against ForwardCommand), `StopCompletely` (against ForwardCommand before reset), `jump_is_allowed` (against ForwardCommand), `move_to_interpreted_state` (against ForwardCommand, to gate `allowJump`).
### is_standing_still (L702-708)
```csharp
public bool is_standing_still()
{
return PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) &&
InterpretedState.ForwardCommand == (uint)MotionCommand.Ready &&
InterpretedState.SideStepCommand == 0 &&
InterpretedState.TurnCommand == 0;
}
```
Same predicate shape as the `StandingLongJump` arm-condition inlined in `charge_jump()` (§7) — grounded+walkable, Ready forward, zero sidestep, zero turn. Not called anywhere within these two files (public API surface for external callers, e.g. WeenieObj/game-logic layer, out of scope).
---
## 14. Misc small methods
- **InqStyle (L187-190):** `return InterpretedState.CurrentStyle;` — trivial accessor, returned as `long` despite `CurrentStyle` presumably being `uint` (implicit widening).
- **SetHoldKey (L274-287):** only handles `HoldKey.None` explicitly — if the NEW key is `None` AND the current key was `Run`, clears to `None` and re-applies movement; the `HoldKey.Run` case (or others) falls through the switch with NO explicit case, meaning setting the hold key TO `Run` via `SetHoldKey` is a silent no-op unless `key == RawState.CurrentHoldKey` returns early first — the only way `HoldKey.Run` actually gets set appears to be `set_hold_run` (below), not `SetHoldKey`. Early-return guard: `if (key == RawState.CurrentHoldKey) return;` — no-op if unchanged.
- **set_hold_run (L826-833):**
```csharp
public void set_hold_run(int val, bool cancelMoveTo)
{
if ((val == 0) != (RawState.CurrentHoldKey != HoldKey.Run))
{
RawState.CurrentHoldKey = val != 0 ? HoldKey.Run : HoldKey.None;
apply_current_movement(cancelMoveTo, true);
}
}
```
XOR-style guard: `(val==0) != (CurrentHoldKey != Run)` is true exactly when `val` and the "is currently Run" state disagree, i.e. this is a real toggle. Sets `HoldKey.Run` or `HoldKey.None` (never `Invalid` or others) and re-applies movement with `allowJump=true` unconditionally.
- **SetPhysicsObject (L289-293) / SetWeenieObject (L295-299):** both simply assign the field then call `apply_current_movement(true, true)` (force cancelMoveTo=true, allowJump=true) — any (re)binding of either owning object triggers a full movement re-application.
- **get_adjusted_max_speed (L618-632):**
```csharp
public float get_adjusted_max_speed()
{
var rate = 1.0f;
if (WeenieObj != null)
{
if (!WeenieObj.InqRunRate(ref rate))
rate = MyRunRate;
}
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
rate = InterpretedState.ForwardSpeed / CurrentSpeedFactor;
return rate * 4.0f;
}
```
Note: if currently running, `rate` is OVERWRITTEN entirely by `ForwardSpeed / CurrentSpeedFactor` — the `WeenieObj.InqRunRate`/`MyRunRate` lookup above becomes dead work in that branch. `4.0f` is presumably `RunAnimSpeed` inlined rather than referencing the constant (magic-number duplication — flag for acdream: use the named constant, don't inline `4.0f`).
- **get_max_speed (L665-676):** simpler sibling — `RunAnimSpeed * rate` where `rate` comes from `InqRunRate`/`MyRunRate` fallback, no ForwardCommand override. Two "max speed" methods with different semantics (adjusted = "current effective speed accounting for what's actually playing," max = "theoretical max run speed") — both present, worth checking retail naming/usage split before porting just one.
- **motions_pending (L784-787) — MotionInterp:** `return PendingMotions.Count > 0;` with an XML doc comment noting `PhysicsObj.IsAnimating` is the faster equivalent. **PhysicsObj.motions_pending() (PhysicsObj.cs L4244-4247)** is a DIFFERENT, simpler method: `return IsAnimating;` directly — i.e. PhysicsObj's own `motions_pending()` already takes the fast path the MotionInterp doc-comment recommends; **MovementManager.motions_pending() (L195-200)** delegates to `MotionInterpreter.motions_pending()` (the SLOW `Count>0` path, not the fast `IsAnimating` path) when `MotionInterpreter != null`, else returns `false` — so the three `motions_pending()` overloads across PhysicsObj/MovementManager/MotionInterp are NOT all equivalent-cost, and MovementManager's public-facing one takes the slower route.
- **move_to_interpreted_state (L789-824):** syncs an externally-supplied `InterpretedMotionState` (e.g. from a network update / DR snapshot) into this instance. Computes `allowJump` from the OLD `ForwardCommand` (before `copy_movement_from` overwrites it) via `motion_allows_jump(...) == WeenieError.None`. Replays the incoming state's `Actions` list through a **sequence-stamp wraparound comparator**: `currentStamp = action.Stamp & 0x7FFF`, `serverStamp = ServerActionStamp & 0x7FFFF` (**note: differing mask widths, `0x7FFF` (15 bits) vs `0x7FFFF` (19 bits) — likely a typo, should probably both be the same mask; flag for retail cross-check**), `deltaStamp = abs(currentStamp - serverStamp)`, and picks a "is this newer" test based on whether the delta exceeds `0x3FFF` (half of 15-bit range) — a classic sequence-number wraparound comparison. Actions only replay if `WeenieObj.IsCreature() || action.Autonomous`.
- **ReportExhaustion (L264-272):**
```csharp
public void ReportExhaustion()
{
if (PhysicsObj == null || !Initted) return;
if (WeenieObj == null || WeenieObj.IsCreature() && PhysicsObj.movement_is_autonomous())
apply_raw_movement(false, false);
else
apply_interpreted_movement(false, false);
}
```
Same raw-vs-interpreted split condition shape as `apply_current_movement` but with the WeenieObj null-check ADDED to the raw-path condition (here: `WeenieObj==null || (IsCreature && autonomous)` routes to raw; there: `WeenieObj!=null && !IsCreature || !autonomous` routes to interpreted) — these two conditions are NOT exact logical complements of each other across the two methods (worth a careful truth-table comparison before assuming they're meant to be identical dispatch logic). Both calls pass `cancelMoveTo=false, allowJump=false` — exhaustion reporting never cancels an active moveto and never allows a fresh jump.
MovementManager.ReportExhaustion (L159-165) is a thin delegate with a dead commented-out `// NoticeHandler::RecvNotice_PrevSpellSelection` line, same as HandleEnterWorld.
---
## 15. MovementManager remaining surface (not covered above)
- **CancelMoveTo / HandleUpdateTarget / IsMovingTo / MakeMoveToManager / UseTime:** all thin null-guarded delegates to `MoveToManager` — no MotionInterp involvement. `MakeMoveToManager()` (L112-116) lazy-constructs `MoveToManager` WITHOUT calling any init-state method (contrast with the `MotionInterpreter` lazy-construct sites which always follow with `enter_default_state()`).
- **InqInterpretedMotionState / InqRawMotionState / get_minterp:** identical three-line lazy-construct-with-enter_default_state pattern, just returning a different field (`.InterpretedState`, `.RawState`, or the interpreter itself).
- **unpack_movement(object addr, uint size) (L213):** empty body — `{ }`. Stub/no-op, presumably a retail network-deserialization entry point ACE doesn't need (server already has structured data, doesn't need to unpack a wire buffer here) or hasn't ported. Flag: if named-retail decomp shows this doing real work, it's client-side-only logic ACE correctly skips (server doesn't need to interpret its own outbound format), OR it's a genuine gap — check the decomp before assuming either way.
---
## Summary table — ACE method inventory vs retail-decomp cross-check TODO
| ACE method | File:Line | Retail decomp checked this pass? |
|---|---|---|
| `add_to_queue` | MotionInterp.cs:388 | No — ACE-only pass |
| `MotionDone` | MotionInterp.cs:210 | No |
| `DoMotion` | MotionInterp.cs:112 | No |
| `DoInterpretedMotion` | MotionInterp.cs:51 | No |
| `StopMotion` | MotionInterp.cs:367 | No |
| `StopInterpretedMotion` | MotionInterp.cs:329 | No |
| `StopCompletely` | MotionInterp.cs:301 | No |
| `HitGround` | MotionInterp.cs:175 | No |
| `LeaveGround` | MotionInterp.cs:192 | No |
| `jump` | MotionInterp.cs:710 | No |
| `jump_is_allowed` | MotionInterp.cs:742 | No — **flag: possible null-guard typo L747** |
| `jump_charge_is_allowed` | MotionInterp.cs:729 | No |
| `charge_jump` | MotionInterp.cs:564 | No — **flag: Falling/Fallen + range-inclusivity mismatch vs jump_charge_is_allowed** |
| `get_jump_v_z` | MotionInterp.cs:634 | No |
| `get_leave_ground_velocity` | MotionInterp.cs:654 | No |
| `enter_default_state` | MotionInterp.cs:604 | No — **flag: `// ??` on PendingMotions reset** |
| `apply_raw_movement` | MotionInterp.cs:506 | No |
| `apply_interpreted_movement` | MotionInterp.cs:440 | No — **flag: inline PhysicsObj.StopInterpretedMotion duplication in Turn-stop branch** |
| `apply_current_movement` | MotionInterp.cs:430 | No |
| `adjust_motion` | MotionInterp.cs:394 | No |
| `apply_run_to_command` | MotionInterp.cs:525 | No |
| `contact_allows_move` | MotionInterp.cs:584 | No |
| `motion_allows_jump` | MotionInterp.cs:770 | No — **depends on MotionCommand enum ordinal layout matching retail exactly** |
| `move_to_interpreted_state` | MotionInterp.cs:789 | No — **flag: 0x7FFF vs 0x7FFFF mask width mismatch** |
| `MovementManager.PerformMovement` | MovementManager.cs:124 | No |
| `MovementManager.HandleEnterWorld` | MovementManager.cs:48 | No — **dead/commented; retail NoticeHandler::RecvNotice_PrevSpellSelection not ported** |
| `MotionTableManager.AnimationDone` | MotionTableManager.cs:28 | No |
| `MotionTableManager.CheckForCompletedMotions` | MotionTableManager.cs:63 | No |
All rows above are candidates for Step 0 (`grep named-retail/acclient_2013_pseudo_c.txt` by `CMotionInterp::<method>` — note retail almost certainly uses the `C`-prefixed class name ACE dropped) before any acdream port work proceeds, per CLAUDE.md's mandatory workflow.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
# R3 port work-list — CMotionInterp completion + local-player unification
Inputs: `r3-motioninterp-decomp.md` (verbatim retail extraction, same dir),
`r3-ace-motioninterp.md` (ACE cross-ref, same dir), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R3), R2 boundary
contract `docs/research/2026-07-02-r2-motiontable/r2-port-plan.md` §4, current code
`src/AcDream.Core/Physics/MotionInterpreter.cs` (1426 lines, post-D6/S2a),
`src/AcDream.App/Input/PlayerMovementController.cs` (1521 lines),
`src/AcDream.Core/Physics/InterpretedMotionFunnel.cs`, GameWindow K-fix18 sites.
**Precondition / state at R3 start (updated at vaulting, 2026-07-02):** R1 complete
(`a987cad1` P6). R2 is IN FLIGHT: Q0 (`dc54a3e4`) + Q1 MotionState (`2345da30`) +
Q2 `Motion/CMotionTable.cs` (`98f58db9`, 44 tests) committed;
Q3 (MotionTableManager + `IMotionDoneSink`) in flight, Q4 (adapter cutover +
queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started.
`RemoteMotionSink.cs` still exists (217 lines). **R3 commits W2+ hard-depend on
R2 Q3/Q4/Q5 being shipped** (the `IMotionDoneSink` seam, the
sentinel→`AnimationDone` drain, and funnel-direct `PerformMovement` dispatch are
R2 deliverables R3 consumes). W0/W1 are independent and can start while R2
finishes.
---
## 0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| A1 | **`motion_allows_jump` 0x48 polarity is INVERTED in the BN extraction's "cleaned" note.** r3-motioninterp-decomp §3a annotates `0x48` = "jump allowed" and the range set as a whitelist. But the CALLER `jump_is_allowed` (0x005282b0, §3h) does `eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7;` — nonzero is returned AS THE ERROR, so **0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist** (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: `[0x1000006f,0x10000078]`, `[0x10000128,0x10000131]`, `0x40000008 (Fallen)`, `[0x40000016,0x40000018]`, `[0x4000001e,0x40000039]`, `[0x41000012,0x41000014] (Crouch..Sleeping)`; everything else (incl. **0x40000015 Falling → 0 = pass**) falls through. ACE instead blocks exactly `Falling` and never `Fallen` — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. | decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 | Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp `acclient!CMotionInterp::motion_allows_jump` log arg2+eax while user jumps during walk/run/mid-fall/crouch. **Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges** (ACE's enum-order dependence is the flagged fragility). |
| A2 | **`jump_is_allowed` pending-head peek gate.** Retail (§3h full body): peek `pending_motions.head_` whenever non-empty; if `head.jump_error_code != 0` return it. ACE (L756): `PendingMotions.Count > 1 && First.JumpErrorCode != 0` — the `Count > 1` has no retail counterpart in this decomp. | decomp §3h (no count check) vs ACE L756 | Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 `WeenieObj == null && !WeenieObj.IsCreature()` NPE-typo is CONFIRMED wrong vs retail's `weenie != 0 && IsCreature()==0` gate — do not copy. |
| A3 | **`apply_current_movement` / `SetWeenieObject` / `SetPhysicsObject` dual-dispatch gate: `IsThePlayer` or `IsCreature`?** The decomp extraction (§4c) states ReportExhaustion gates on `IsThePlayer() && movement_is_autonomous()` and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on `IsCreature`. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, `IsThePlayer` is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down `apply_raw_movement` and corrupts their interpreted state from an empty raw state. | decomp §4c text vs ACE L430/L497 | Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes `IsThePlayer` from `IsCreature`. If still ambiguous, cdb bp with `this` dump on a remote-visible session. Default: **IsThePlayer-based for apply_current_movement/ReportExhaustion** (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical). |
| A4 | **MovementParameters bit numbering.** DoMotion reads byte-1 bits: `&8` → SetHoldKey (=0x800 overall), `&0x20` → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) | 0x1ee0f`. | decomp §0/§2 vs acclient.h:31453 comment | Derive the absolute bit positions from the ctor default 0x1ee0f + ACE MovementParameters property names (CancelMoveTo/SetHoldKey/ModifyRawState/ModifyInterpretedState/DisableJumpDuringLink/…) which ACE mapped from the same bitfield. Document the full flag table in the pseudocode doc; the C# port uses named bool properties + a `ToBitfield()` only if the wire ever needs it. |
| A5 | **`get_jump_v_z` no-weenie fallback + near-zero path.** BN garbles the `extent < 0.0002` path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: `< EPSILON → 0.0f`, no-weenie → `10.0f`, InqJumpVelocity false → `0.0f`. Current acdream uses ACE's shape but with epsilon **0.001** (wrong — retail literal is `0.000199999995f`). | decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 | Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless. |
| A6 | **`get_leave_ground_velocity` zero-fallback matrix direction.** BN reads `m_fl2gv` (local→global) applied to the velocity; ACE does `GlobalToLocalVec(Velocity)`. Since the result feeds `set_local_velocity` (a LOCAL vector) and `m_velocityVector` is global, global→local is the only coherent reading — BN row/column confusion. Current `Quaternion.Inverse(Orientation)` transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). | decomp §3d vs ACE L654-663 vs current :1011-1029 | Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction. |
| A7 | **`MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed node's motion id; the interp body reads NEITHER param in this build — **pop the HEAD unconditionally, never match by motion id**. Keep the params for R5 signature parity. | decomp §1c/§6d | Documented adjudication only. |
| A8 | **`enter_default_state` does NOT clear `pending_motions`.** Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the `{0, 0x41000003, 0}` sentinel to whatever queue exists — no drain. ACE resets `PendingMotions = new LinkedList` with the author's own `// ??`. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. | decomp §4d/§6g vs ACE L604-616 | Documented adjudication; test asserts pre-existing nodes survive an enter_default_state. |
| A9 | **`StopCompletely` jump-snapshot quirk**: `motion_allows_jump` is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). | decomp §5a + ACE | Textual pin only; golden asserts the queued node's error code reflects the pre-stop command. |
| A10 | **Error-code renumber vs current acdream `WeenieError`.** Retail numeric usage: `8`=no physics obj, `0x24`=not grounded (jump_is_allowed airborne), `0x3f/0x40/0x41`=combat-stance crouch/sit/sleep rejects, `0x42`=combat-stance chat-emote-bit reject, `0x45`=action depth ≥6, `0x47`=constrained OR stamina OR bad MovementStruct.type, `0x48`=motion/position blocks jump, `0x49`=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). | decomp §10 vs MotionInterpreter.cs:113-127, :1064 | W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename. |
**W0 cdb capture (one session serves all of R3):** bp `CMotionInterp::MotionDone` /
`add_to_queue` / `jump` / `jump_is_allowed` / `jump_charge_is_allowed` / `charge_jump`
/ `motion_allows_jump` / `HitGround` / `LeaveGround` / `StopCompletely` / `set_hold_run`
/ `SetHoldKey` / `enter_default_state` with arg+ret logging (pattern:
tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk /
tap-jump / full-charge jump / running jump / standing long-jump (charge while idle,
then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched
(blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge
(LeaveGround without jump — the momentum fallback) / land / emote-while-running
(action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout
(HandleExitWorld drain).
---
## 1. ITEMIZED GAPS — current vs retail (R3 scope)
Severity: **BLOCKER** = R3's conformance harness meaningless without it; **HIGH** =
visible behavior wrongness / blocks R4-R6; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| J1 | **No `pending_motions` queue** — no MotionNode, no `add_to_queue` (append `{context_id, motion, jump_error_code}`), no `MotionDone` (head action-class 0x10000000 → unstick + Interpreted+Raw `RemoveAction`, then unconditional head pop), no `motions_pending`, no `HandleExitWorld` drain loop. The R2 `IMotionDoneSink` signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). | add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 | `MotionInterpreter.cs:1394` ("Idle add_to_queue(Ready) bookkeeping lands with S3") + `:1417` ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists | **BLOCKER** |
| J2 | **No action FIFO on the states** — retail `RawMotionState`/`InterpretedMotionState` each carry an actions list (`ApplyMotion`/`RemoveMotion`/`AddAction`/`RemoveAction`/`GetNumActions`); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's `InterpretedMotionState` (MotionInterpreter.cs:185-210) + `LegacyRawMotionState` (:148-179) are flat 6/7-field structs; the outbound `RawMotionState` packs an always-empty action list (register TS-24). | acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair | `MotionInterpreter.cs:148-210`; `Core/Physics/RawMotionState.cs` (packer type, no actions) | **BLOCKER** |
| J3 | **DoMotion is a 12-line approximation.** Retail 0x00528d20: interrupt on sign bit; `SetHoldKey` on bit-SetHoldKey BEFORE `adjust_motion`; fresh local `MovementParameters` re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (`current_style != 0x8000003d` rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and `motion & 0x2000000` → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate `DoInterpretedMotion(adjusted, localParams)`; mirror `RawState.ApplyMotion(ORIGINAL id)` on the mirror bit. Current: writes RawState.Forward\* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. | 0x00528d20 @306159; ACE L112-158 | `MotionInterpreter.cs:448-462` | **BLOCKER** |
| J4 | **TWO parallel DoInterpretedMotion implementations, both partial.** The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel `DispatchInterpretedMotion` (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no `Dead → RemoveLinkAnimations`, no jump_error_code compute (double `motion_allows_jump` check: incoming motion, then ForwardCommand if non-action) + `add_to_queue`, no `ModifyInterpretedState` param, no `CurCell==null → RemoveLinkAnimations` tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare `sink.StopMotion`): missing post-stop `add_to_queue(ctx, Ready, None)` + raw `RemoveMotion` mirror. They must MERGE into one verbatim pair. | DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 | `MotionInterpreter.cs:473-491`, `:498-553`, `:1408-1425`, `:1358-1396` | **BLOCKER** |
| J5 | **Jump gate family absent**: no `motion_allows_jump` (0x005279e0 literal-range blocklist, A1), no `jump_charge_is_allowed` (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no `charge_jump` (0x005281c0: arms `standing_longjump` only when on-ground + fwd==Ready + no sidestep/turn). `jump_is_allowed` (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and `JumpStaminaCost` (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). | §3a/3b/3e/3h; ACE L729-779 | `MotionInterpreter.cs:1053-1071`; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) | **BLOCKER** |
| J6 | **StandingLongJump armed in the WRONG function** — the S2a-flagged misattribution: acdream arms it as a side effect of `contact_allows_move` (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in `charge_jump`. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. | charge_jump 0x005281c0 @305448 | `MotionInterpreter.cs:1139-1148` | **HIGH** |
| J7 | **jump() missing `interrupt_current_movement`** (retail always cancels the in-flight transition first; ACE `cancel_moveto`). And the CONTROLLER manually calls `LeaveGround()` + pre-captures `get_jump_v_z` at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (`set_on_walkable(false)` → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). | jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) | `MotionInterpreter.cs:943-958`; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) | **HIGH** |
| J8 | **HitGround/LeaveGround bodies incomplete**: both missing the creature gate (`weenie==null OR IsCreature`) and `RemoveLinkAnimations`; LeaveGround missing the state-0x400 gate, `apply_current_movement(0,0)` re-sync, and the autonomous flag on `set_local_velocity(v, 1)`. **This is the retail mechanism K-fix18 fakes**: leaving ground strips pending link animations, so Falling engages instantly — no `skipTransitionLink` needed. | HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 | `MotionInterpreter.cs:1166-1198`; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 | **HIGH** |
| J9 | **StopCompletely missing** interrupt, the A9 jump snapshot, `add_to_queue(0, Ready, jumpErr)`, and cell-null `RemoveLinkAnimations`; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). `set_velocity(Zero)` stands in for `StopCompletely_Internal` (acceptable, keep + note). | 0x00527e40 @305208 | `MotionInterpreter.cs:577-602` | **HIGH** |
| J10 | **No `enter_default_state` / `initted`**: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + `initted=1` + unconditional LeaveGround; nothing gates `apply_current_movement`/`ReportExhaustion` on initted. | 0x00528c80 @306124 | constructors at `MotionInterpreter.cs:386-398` only | **BLOCKER** |
| J11 | **`apply_current_movement` is a direct-velocity approximation**, not the retail dual dispatch (`initted` gate → IsThePlayer/autonomous (A3) → `apply_raw_movement(cancelMoveTo, allowJump)` else `apply_interpreted_movement(...)`). The grounded `set_local_velocity(get_state_velocity())` write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no `(cancelMoveTo, allowJump)` param plumbing anywhere — `DisableJumpDuringLink = !allowJump` feeds the jump_error_code path (ACE L538/L232). | 0x00528870 @305838 (A3 pin); ACE L430-438 | `MotionInterpreter.cs:905-925`; funnel `ApplyInterpretedMovement` :1358 (no params) | **HIGH** |
| J12 | **No `ReportExhaustion`** (initted gate + IsThePlayer/autonomous dual dispatch, both `(0,0)` args); no `movement_is_autonomous` flag on PhysicsBody; no `IsThePlayer` on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE `NoticeHandler.RecvNotice_PrevSpellSelection` breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. | 0x005288d0 @305861 | nothing | MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events) |
| J13 | **No `SetHoldKey`/`set_hold_run`** — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). `HoldKey_None=1 / HoldKey_Run=2` encoding already matches acdream's HoldKey enum usage. | set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 | `MotionInterpreter.cs:340` (bare CurrentHoldKey field, written by apply_raw_movement only) | **HIGH** |
| J14 | **No `CMotionInterp::PerformMovement` 5-way dispatch** with `CheckForCompletedMotions` after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). | 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 | `MotionInterpreter.cs:400-430` | **BLOCKER** |
| J15 | **Local player motion state machine lives in PlayerMovementController, level-triggered.** Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls `_motion.jump` + manual LeaveGround (:986-1028); outbound wire commands re-derived from `input` (:1264-1328) instead of read from the raw state; `LocalAnimationCommand`/`LocalAnimationSpeed` synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. | DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) | PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) | **BLOCKER** for the stage's named deliverable |
| J16 | **Epsilons + codes**: `JumpExtentEpsilon = 0.001` used in get_jump_v_z AND get_leave_ground_velocity — retail is `0.000199999995f` in both (§10); WeenieError names shuffled vs retail numerics (A10). | §3c/§3d/§10 | `MotionInterpreter.cs:278`, :978, :1020; :113-127 | MED |
| J17 | **`is_standing_still` absent** (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail `IsAnimating`/`IsMovingOrAnimating`. **Do NOT invent a combined IsAnimating helper** — retail has none (decomp §7f negative result); ACE's `PhysicsObj.IsAnimating` flag is an ACE-ism (retail queries `motions_pending`). | 0x00527fa0 @305309 | nothing | LOW |
| J18 | **`adjust_motion` creature guard unwired** (register TS-34): `IWeenieObject.IsCreature()` exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. | adjust_motion @305343 gate | `MotionInterpreter.cs:756-758` | LOW |
| J19 | **VectorUpdate remote-jump path bypasses the interp** (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via `SetCycle(..., skipTransitionLink:true)` (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes `rm.Motion.LeaveGround()` (+ the funnel's existing Falling dispatch). | SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain | GameWindow.cs:4802-4833 | **HIGH** |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| Inbound funnel dispatch order: `MoveToInterpretedState` (flat copy + 15-bit stamp gate + local-echo skip) / `ApplyInterpretedMovement` axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite | 0x005289c0 / 0x00528600 (S2a, `7b0cbbda`) | `MotionInterpreter.cs:1312-1396` — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar |
| `contact_allows_move` verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) | 0x00528240 (S2a rewrite) | `MotionInterpreter.cs:1096-1151` — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it) |
| `adjust_motion` / `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp | D6/D6.2a (`0f099bb6`) | `MotionInterpreter.cs:654-888` — untouched except J18's one-line guard |
| `GetMaxSpeed` ×4.0 (UN-2 byte-verified vs the BN x87 dropout) | 0x00527cb0 | `MotionInterpreter.cs:1235-1245` |
| `GetCycleVelocity` Option-B forward-velocity source (MotionData.Velocity over the constant) | adaptation, r03 §1.3 | `MotionInterpreter.cs:382`, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body |
| `StopCompletely_Internal` stand-in = `PhysicsObj.set_velocity(Zero)` | 0x0050f5a0 is CPhysicsObj-side | `MotionInterpreter.cs:599` — keep + register note (full port is physics-layer scope) |
| Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula | GetPowerBarLevel 0x0056ADE0 illegible | PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives |
| Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent | L.5/L.2c/B.6 anchors in-file | PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order |
| Auto-walk (B.6) block incl. `DoMotion(Ready)`/`DoMotion(WalkForward)` drive | MovementManager case 6 stand-in | PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager |
| Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs | L.2b/D6.2b/L.1b | untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation) |
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager | plan "absorbed" + R2 | untouched — R3 sits between MovementManager(-to-be) and the R2 queue |
| `0x41000003` Ready sentinel / `0x10000000` action-class bit / `0x8000003d` NonCombat conventions | §10 | already the file-wide constants |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: extend `src/AcDream.Core/Physics/MotionInterpreter.cs` in place (it IS
the CMotionInterp port; plan rule 4's `Motion/` dir holds the R2 table/queue classes it
talks to). Tests: `tests/AcDream.Core.Tests/Physics/` (existing MotionInterpreter
suites + `Motion/` for new fixtures). Every commit: build+test green, register rows
added/retired in-commit.
**W0 — pseudocode + ambiguity pinning (docs only).**
`docs/research/2026-07-0x-motioninterp-pseudocode.md` from r3-motioninterp-decomp.md,
resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing;
correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb
session (§0 protocol) — feeds W2/W3/W5/W6 goldens.
Fixture source: **cdb** (live retail). Deps: none (parallel with R2 completion).
**W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber.** (closes J2, J16-codes/A10)
`InterpretedMotionState`/`RawMotionState` gain the actions list
(`AddAction`/`RemoveAction`/`GetNumActions`/`ApplyMotion`/`RemoveMotion` per retail
member set; the outbound packer's empty-list TS-24 comment updates to "populated by
CMotionInterp"); fold `LegacyRawMotionState` into the full-field
`AcDream.Core.Physics.RawMotionState` (one raw-state type; delete the legacy struct);
`MovementParameters` verbatim class (ctor defaults: distance_to_object=0.6,
fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield
0x1ee0f per A4 pin; named bool properties); `MotionNode {ContextId, Motion,
JumpErrorCode}`; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix
0x24/0x47/0x48 semantics).
Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green
under the raw-state fold.
Fixture source: **synthetic**. Deps: W0.
**W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands).** (closes J1, J17; §4 below is this commit's wiring spec)
`MotionInterpreter` gains `PendingMotions` (LinkedList<MotionNode> — register note
reuses AD-34 wording), `add_to_queue` 0x00527b80, `motions_pending` 0x00527fe0,
`MotionDone(uint motion, bool success)` 0x00527ec0 (head-peek action-class → unstick
seam (no-op Action? callback until R5 StickyManager — register row) +
`InterpretedState.RemoveAction()` + `RawState.RemoveAction()`; unconditional head pop;
NEVER match by motion id per A7), `HandleExitWorld` 0x00527f30 (loop),
`is_standing_still` 0x00527fa0, and `motion_allows_jump` 0x005279e0 (pure fn, pinned
per A1 — needed here because every add_to_queue caller computes jump_error_code).
Wire the queue's PRODUCERS into the funnel: `DispatchInterpretedMotion` success path
computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) +
`add_to_queue(ctx, motion, err)`; `ApplyInterpretedMovement`'s turn-stop adds
`add_to_queue(ctx, Ready, None)` (@305775); funnel StopMotion path adds the post-stop
Ready node (@305657). GameWindow: replace R2-Q4's diagnostic `IMotionDoneSink` binding
with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH
`manager.HandleExitWorld()` (R2) and `interp.HandleExitWorld()` (retail has both
layers' drains).
Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop
order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case
suite green (dispatch unchanged, queue side effects asserted additively); end-to-end
chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram).
Fixture source: **synthetic + W0 cdb goldens** (add_to_queue arg conformance).
Deps: W1 + **R2 Q3/Q4 shipped**.
**W3 — jump family verbatim.** (closes J5, J6, J7-interp-side, J16-epsilons)
`jump_charge_is_allowed` 0x00527a50; `charge_jump` 0x005281c0 (arms StandingLongJump;
**DELETE the contact_allows_move side effect :1139-1148** — J6); `get_jump_v_z`
(epsilon → 0.000199999995f; A5 fallback pinned); `get_leave_ground_velocity` (same
epsilon; A6 direction confirmed); `jump_is_allowed` 0x005282b0 full chain (creature/
state-0x400 entry shape → 0x24; `IsFullyConstrained` seam on PhysicsBody (stub false +
register row); pending-head peek per A2; charge → motion_allows_jump(fwd) →
`JumpStaminaCost` (new IWeenieObject member; PlayerWeenie implements — real gating
stays TS-5-deferred, row survives)); `jump` 0x00528780 (+`interrupt_current_movement`
seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains
`IsThePlayer()` (default false; PlayerWeenie true) for W4's A3 dispatch.
Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming
matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit;
blocked-motion jump attempts (crouch range per pinned A1 table).
Fixture source: **W0 cdb goldens + synthetic**. Deps: W2.
**W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED.** (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along)
`HitGround` 0x00528ac0 verbatim (creature gate + Gravity + `RemoveLinkAnimations` seam
+ `apply_current_movement(false, true)`); `LeaveGround` 0x00528b00 verbatim (velocity
+ autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations +
apply_current_movement); `RemoveLinkAnimations` seam = `Action?` on MotionInterpreter,
App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per
CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence);
`enter_default_state` 0x00528c80 (A8: append sentinel, no clear; `Initted` field;
LeaveGround tail); `Initted` gates apply_current_movement + ReportExhaustion;
`apply_current_movement` becomes the verbatim dual dispatch per A3 (the direct
grounded-velocity write MOVES to the controller-side call site unchanged — register
row: velocity-from-state adaptation until R6 root motion); `ReportExhaustion`
0x005288d0 (A3 gate; `movement_is_autonomous` flag added to PhysicsBody, set true at
the local-player chokepoint, false on DR-applied updates); `SetHoldKey` 0x00528bb0 +
`set_hold_run` 0x00528b70; `SetPhysicsObject`/`SetWeenieObject` re-apply pattern.
**PlayerMovementController transition wiring:** grounded→airborne edge (section 5,
:1245-1257) now calls `_motion.LeaveGround()`; the jump-fire block stops calling
LeaveGround manually — `jump()` sets on_walkable(false) and the SAME frame's
transition detection fires LeaveGround (ordering test); JumpAction wire velocity read
from the LeaveGround-computed vector (get_leave_ground_velocity at fire time —
byte-identical values, source unified).
**K-fix18 DELETION (both sites + the parameter):** GameWindow :4817-4833 remote
VectorUpdate → `rm.Motion.LeaveGround()` replaces the forced SetCycle (J19); GameWindow
:10194-10224 skipLink computation + arg deleted; `AnimationSequencer.SetCycle`'s
`skipTransitionLink` parameter + its post-dispatch link-clear (whatever shape R2-Q4
left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row
kept → R3") retired — if no row exists, the sweep note records it was comment-only.
Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock);
ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering;
enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch
truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey
None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling
instantly WITHOUT K-fix18.
Fixture source: **synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces)**.
Deps: W3 (+ R2-Q4 committed for the sequencer seam shape).
**W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush.** (closes J3, J4, J9, J14)
`DoMotion` 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params
re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45;
raw mirror ORIGINAL id); `StopMotion` 0x00528530 (mirror shape, no gates, raw
RemoveMotion ORIGINAL id); `StopCompletely` 0x00527e40 (A9 verbatim incl. quirk +
exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); **merge**
legacy `DoInterpretedMotion`/`StopInterpretedMotion` overloads with the funnel
Dispatch backend into ONE verbatim `DoInterpretedMotion(uint, MovementParameters)` /
`StopInterpretedMotion(uint, MovementParameters)` (StandingLongJump state-only branch;
Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's
interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy =
`PhysicsObj.CellId == 0`); **DELETE** `ApplyMotionToInterpretedState` (:1253-1282) +
the legacy overloads; `PerformMovement` gains the per-op
`CheckForCompletedMotions` seam call (bound to the entity's MotionTableManager
UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch.
Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror
discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a
183-case suite green under the merged backend (assertions unchanged — dispatch order
is funnel-owned); zero-tick flush test (stop-with-no-link completes same call).
Fixture source: **W0 cdb goldens + suite**. Deps: W2+W4 (+ R2-Q5 so the funnel's sink
IS PerformMovement dispatch — otherwise the merge has two consumers).
**W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices).** (closes J15)
PlayerMovementController sheds the motion state machine; edge-driven retail input:
- **DIES in the controller:** per-frame RawMotionState rebuild + `apply_raw_movement`
call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4);
`localAnimCmd`/`LocalAnimationSpeed` synthesis (:1283-1296, :1340-1359 change
detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides —
the sequencer is now driven by the interp's own dispatch, not by
UpdatePlayerAnimation); `MovementResult.LocalAnimationCommand/LocalAnimationSpeed`
fields (and GameWindow consumers).
- **MOVES to MotionInterpreter calls (edge-driven):** input edges → `PerformMovement`:
W/S press → `DoMotion(WalkForward/WalkBackward, params)`; release →
`StopMotion(same)`; strafe/turn keys likewise; Shift edge → `set_hold_run(run,
interrupt:true)` (retail 0x006b33ca shape); jump release → `jump(extent)` (charge
accumulation STAYS controller-side, AP-24). The controller keeps a tiny
prev-key-state edge detector — that IS retail's CommandInterpreter altitude.
- **STAYS in the controller (input/camera/physics/wire):** Yaw + mouse turn
(:938-946); keyboard-turn Yaw integration reading
`_motion.InterpretedState.TurnCommand/TurnSpeed` (:932-937, unchanged); grounded
velocity write from `get_state_velocity` (:966-976 — the R6-deferred adaptation);
physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8);
auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 `DoMotion(Ready)`
arrival-idle becomes `StopCompletely()` — retail's teleport idle is a full stop;
note in commit); outbound section 6 now READS `_motion.RawState` (commands + per-axis
hold keys) instead of re-deriving from `input` — wire bytes identical (golden-byte
packer tests prove it).
- **GameWindow call sites that change:** `UpdatePlayerAnimation(result)` (:10062-10234)
DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's
MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5
sink shape); the airborne-Falling override falls out of contact_allows_move +
apply_current_movement (retail mechanism, already live in the funnel); the #45
sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are
re-verified against) the CMotionTable-driven speedMod path — expected-diff
annotations required; :4344 `ApplyServerRunRate` retargets to `my_run_rate` +
`apply_current_movement` (same effect, no InterpretedState poke); :7953-7971
JumpAction build reads the W4-unified launch vector; :7975 call deleted with
UpdatePlayerAnimation.
Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch
traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle
identities + speeds, expected-diffs documented (Falling engage now via
link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump
byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION +
observer view; **ONE user visual pass** (walk/run/toggle/strafe/turn/jump instant
Falling/landing/emote-mid-run — the stage acceptance).
Fixture source: **pre-cutover recorded traces + golden-byte + suite**. Deps: W5.
**W7 — register sweep + roadmap + digest (docs/cleanup only).**
Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard),
plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add):
TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate),
TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30
(re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op
seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4),
grounded velocity-from-state write (→R6 root motion), managed LinkedList<MotionNode>
vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE.
Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory
digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end).
Deps: W6.
Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates
using queue → transitions using gates → dispatchers using all → cutover). W4 and W6
both touch GameWindow — single-agent each.
---
## 4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam
R2's contract (r2-port-plan §4) delivers: `CSequence` G5 gate → `AnimationDoneSentinel`
→ GameWindow anim tick drain (:9876-9890 area) → `manager.AnimationDone(true)` per
sentinel + `manager.UseTime()` once per tick → `MotionTableManager` countdown pop →
action-class head pops the MANAGER's own `MotionState.RemoveActionHead()` (R2-OWNED)
`IMotionDoneSink.MotionDone(uint motion, bool success)` — bound in R2-Q4 to a
diagnostic recorder.
R3-W2 replaces the recorder:
```
MotionTableManager (per entity) [R2-Q3, exists]
└─ IMotionDoneSink.MotionDone(motion, success) [R2 seam — signature UNCHANGED]
└─ R3: the entity's MotionInterpreter.MotionDone(motion, success)
[stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 →
MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone
0x00527ec0 — both intermediates are null-guarded relays with ZERO
logic; R5 inserts MovementManager between manager and interp
WITHOUT changing this behavior]
body: head-peek pending_motions;
head.Motion & 0x10000000 → unstick seam (no-op → R5)
+ InterpretedState.RemoveAction()
+ RawState.RemoveAction();
pop head UNCONDITIONALLY (A7: motion id + success are IGNORED —
positional protocol, never match-by-id, never branch on success)
```
Binding points (App, per entity, at creation):
- **Remote:** where `RemoteMotion` is constructed (each remote already owns a
`MotionInterpreter``remoteMot.Motion`, GameWindow.cs:4646): the same construction
that gives the entity its MotionTableManager (R2-Q4/Q5) passes
`sink: remoteMot.Motion` (MotionInterpreter implements IMotionDoneSink directly).
- **Local player:** `PlayerMovementController` exposes its `MotionInterpreter`
(new internal property `Motion`); GameWindow binds the player entity's manager to it
when the player's AnimatedEntity/sequencer is created (same site as
`AttachCycleVelocityAccessor`, :384).
Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b):
1. **Two queues, never merged**: `MotionTableManager.pending_animations` (CPartArray
side, duration-in-ticks) vs `CMotionInterp.pending_motions` (movement side,
waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000
bit: manager's MotionState action FIFO (popped in R2's AnimationDone),
InterpretedState.actions + RawState.actions (popped in R3's MotionDone).
2. **Tick placement provisional until R6**: the sentinel drain + UseTime stay at the
R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush:
`CMotionInterp.PerformMovement` calls the manager's CheckForCompletedMotions after
every op (W5), which fires MotionDone same-call for zero-tick nodes.
Success-flag adjudication (closes r2-plan §4's warning): retail
`CMotionInterp::MotionDone` never reads the flag in this build — R3's jump/HitGround
logic does NOT key off it after all. The flag's only real consumers are
server/weenie-side (`OnMotionDone`) and the R2 manager's own countdown semantics.
Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded
true from CheckForCompletedMotions) for R5 signature parity; document as inert at the
interp.
Enter/exit world: exit paths call `manager.HandleExitWorld()` (drains
pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step)
THEN `interp.HandleExitWorld()` (drains any remainder — retail runs both layers'
drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld
independently). Enter-world: manager-side only (`remove_all_link_animations` + drain);
`MovementManager::HandleEnterWorld`'s interp relay is the mis-symbolicated tailcall
(decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative
result, decomp §6e/§1d).
---
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
- `CPhysicsObj::IsAnimating` / `IsMovingOrAnimating`**not in this PDB** (decomp
§7f). ACE's `PhysicsObj.IsAnimating` bool is an ACE-ism. acdream queries
`motions_pending()` / `is_standing_still()` / `CPartArray.HasAnims` individually.
- `MovementManager::HandleUpdateTarget` — not in this PDB (§6j); likely
MoveToManager-internal (R4). Do not stub.
- `CMotionInterp::HandleEnterWorld` — does not exist (§6e); enter-world work is
manager/CPartArray-side only.
- `MakeMoveToManager` / MoveToManager internals — R4. R3 leaves the B.6 auto-walk +
`interrupt_current_movement`/`cancel_moveto` as registered no-op seams.
- `NoticeHandler::RecvNotice_PrevSpellSelection` (HandleEnterWorld/ReportExhaustion
relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research
note only (no ACE reference exists — decomp-direct if ever needed).
- `LeaveGround` address is **0x00528b00** — the 0x00529710 in MotionInterpreter.cs's
doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations
(several header comments cite old FUN_ addresses — sweep them to named symbols).

View file

@ -0,0 +1,726 @@
# R3-W6 cutover-execution-map — local player motion unification
Research-only doc. Re-anchors `r3-port-plan.md` §3 W6 (which cites stale
line numbers) against the CURRENT code, post-W1-W5. Every anchor below was
read directly from the working tree at HEAD (`e214acdf`) during this
session — no line number here is inherited from the plan.
Inputs read: `r3-port-plan.md` §3 W6 + §1 row J15 + §4 (sink wiring
contract); `r3-motioninterp-decomp.md` §5c/§8 (`set_hold_run` callers);
`src/AcDream.App/Input/PlayerMovementController.cs` (full file, 1547
lines); `src/AcDream.App/Rendering/GameWindow.cs` (targeted sections via
subagent + direct reads); `src/AcDream.Core/Physics/MotionInterpreter.cs`
(full file, 3083 lines); `src/AcDream.Core/Physics/RawMotionState.cs`;
`src/AcDream.Core/Physics/Motion/MovementParameters.cs`; the test tree.
---
## §1 Current-state inventory — every member/block that DIES
### 1a. Per-frame `RawMotionState` rebuild + `apply_raw_movement(RawMotionState)` call
**`PlayerMovementController.cs:892-924`** (comment block + body, inside
`Update`). Every frame, regardless of whether input changed, builds a
fresh `RawMotionState` from `MovementInput` and pushes it through the
snapshot-consuming overload:
```csharp
var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
var raw = new RawMotionState { CurrentHoldKey = axisHold, ForwardCommand = ..., ... };
_motion.apply_raw_movement(raw);
```
This is retail's `apply_raw_movement(RawMotionState raw)` overload
(`MotionInterpreter.cs:1346-1371`) — it copies 6 fields into
`InterpretedState` and normalizes each axis via `adjust_motion`, but does
**not** drive velocity or dispatch through a sink. It's a **level-triggered**
substitute for retail's **edge-triggered** `DoMotion`/`StopMotion`/
`set_hold_run` calls that mutate `RawState` incrementally via
`RawMotionState.ApplyMotion`/`RemoveMotion`. DIES in W6 — replaced by §2's
edge table.
### 1b. Section 2 grounded-velocity write (STAYS, not a W6 kill — flagged for clarity)
**`PlayerMovementController.cs:962-991`**. Reads
`_motion.get_state_velocity()` and writes `_body.set_local_velocity(...)`
when grounded. The plan (§3 W6 "STAYS") keeps this — it's the R6-deferred
"grounded velocity from state" adaptation (register row). Listed here only
so the doc distinguishes it from 1a, which it's textually adjacent to.
### 1c. Jump charge/fire block
**`PlayerMovementController.cs:993-1043`**. Charge accumulates via
`_jumpCharging`/`_jumpExtent` (unchanged — AP-24, stays controller-side).
On release, calls:
```csharp
var jumpResult = _motion.jump(_jumpExtent);
```
**Confirmed gap (not in the plan's text, found this session):**
`MotionInterpreter.ChargeJump()` (`MotionInterpreter.cs:1765-1789`, the
verbatim `charge_jump` port, the ONLY place `StandingLongJump` arms) is
**never called** by the controller today — only by
`MotionInterpreterJumpFamilyTests.cs`. The doc comment at
`MotionInterpreter.cs:1756-1762` says outright: *"no regression today
since nothing calls ChargeJump yet, so remotes/local both continue
unaffected."* W6 must add a `ChargeJump()` call at charge-START (the
retail caller is the SmartBox/input boundary at `0x0056afac`, out of
CMotionInterp's own scope but exactly where the controller's charge
block lives). Without this, `StandingLongJump` never arms for the local
player post-cutover either — same bug, just moved. See §6 risk R1.
The manual `LeaveGround()` pre-capture this block used to need was
**already deleted in W4** (comment at `:1016-1021` confirms: "the manual
LeaveGround call is DELETED — jump() clears OnWalkable, and the SAME
frame's grounded→airborne edge... fires `_motion.LeaveGround()`"). This
part of the plan's W6 description is stale — it's W4-done, not a W6 task.
### 1d. `LocalAnimationCommand` / `LocalAnimationSpeed` synthesis
**`MovementResult` record fields**: `PlayerMovementController.cs:55`
(`LocalAnimationCommand`) and `:62` (`LocalAnimationSpeed`), part of the
record at `:42-65`.
**Synthesis sites**:
- `:1308-1322``localAnimCmd` set from `input.Forward`/`input.Backward`
+ `_weenie.InqRunRate` (Run cycle selection independent of wire command).
- `:1443-1481` — K-fix5 `localAnimSpeed` computation (run-rate pacing for
Backward+Run/Strafe+Run) + auto-walk override block
(`_autoWalkMovingForwardThisFrame`/`_autoWalkTurnDirectionThisFrame`
branches overwrite `localAnimCmd`/`localAnimSpeed`).
- `:1355-1377` — change-detection block includes `localAnimCmd !=
_prevLocalAnimCmd` as a `changed` trigger (7th line of the OR-chain,
`:1371`) and tracks `_prevLocalAnimCmd` (`:191`, `:1377`).
- `:1503-1504` — final `MovementResult` construction passes both fields
through.
DIES: the two fields, all three synthesis sites, and the
`_prevLocalAnimCmd` change-detection leg (the OTHER six `changed`
triggers survive unchanged — see §5).
### 1e. `SetPosition`'s arrival-idle `DoMotion(Ready)`
**`PlayerMovementController.cs:839`**, inside `SetPosition(Vector3, uint,
Vector3)`:
```csharp
_motion.DoMotion(MotionCommand.Ready, 1.0f);
```
Per the plan: "the `:825` `DoMotion(Ready)` arrival-idle becomes
`StopCompletely()` — retail's teleport idle is a full stop." Confirmed
`StopCompletely()` (`MotionInterpreter.cs:1054-1084`) is the correct
retail-faithful substitute: it resets forward/sidestep/turn COMMANDS
(not speeds, per J9), zeros `PhysicsObj.set_velocity`, enqueues the A9
jump-snapshot node, and fires `RemoveLinkAnimations` if uncelled — a
strictly more complete reset than the current one-field `DoMotion(Ready)`
call. Same substitution applies to the identical call inside
`DriveServerAutoWalk`'s turn-in-place branch (`:732`) — **the plan does
NOT call this one out**, but it is textually the same pattern
(`_motion.DoMotion(MotionCommand.Ready, 1.0f)`) serving the same "go
idle" role mid-auto-walk. Flagged as a judgment call in §6 (R4) — recommend
leaving `:732` as `DoMotion(Ready)` since auto-walk survives R3 verbatim
per the KEEP LIST and StopCompletely's jump-snapshot/queue side effects
are not obviously wanted mid-auto-walk-turn.
### 1f. Shift/Run detection
**Today**: `input.Run` is read directly into `axisHold` (1a) every frame
— no edge detection exists; the level-triggered rebuild makes edge
detection moot today. W6 replaces this with an explicit prev-Shift-state
edge feeding `set_hold_run(bool holdingRun, bool interrupt)`
(`MotionInterpreter.cs:2453-2463`), which itself has an internal XOR
no-op guard (so calling it every frame with the same value is safe, but
the controller should still gate on an edge per the plan's "tiny
prev-key-state edge detector").
### 1g. Outbound section 6 — re-derivation from `input`
**`PlayerMovementController.cs:1288-1354`** (`outForwardCmd`/
`outForwardSpeed`/`outSidestepCmd`/etc.) re-derives the wire commands
from `MovementInput` fields (`input.Forward`, `input.StrafeRight`, etc.)
completely independently of `_motion.RawState`, which by this point in
the frame (post 1a) already holds equivalent values. This is redundant
computation that DIES — the outbound-wire-build in GameWindow (§4/§5)
should read `_motion.RawState` (now the SAME instance the controller has
been mutating via `DoMotion`/`StopMotion`/`set_hold_run`) directly instead
of `MovementResult.ForwardCommand`/etc. **Note**: this doc treats "which
fields of `MovementResult` survive" as a §5 design decision, not a
foregone conclusion — see §5.
### 1h. GameWindow: `UpdatePlayerAnimation` (DELETE) + its caller
**Definition**: `GameWindow.cs:10139-10307` (169 lines).
**Single caller**: `GameWindow.cs:8006`, inside `OnUpdate`'s
`if (_playerController is not null)` block, immediately after the
JumpAction wire-send block (`:7984-8002`).
Reads `result.IsOnGround` (airborne→Falling override, `:10164-10165`),
`result.LocalAnimationCommand` (`:10166`, preferred source),
`result.ForwardCommand`/`SidestepCommand`/`TurnCommand` (`:10168-10172`,
fallback chain when `LocalAnimationCommand` is null),
`result.LocalAnimationSpeed` (`:10186`, `:10255-10257`, feeds `SetCycle`).
Calls `ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed *
animScale)` directly (`:10297`) — this is a **parallel path** to
`MotionTableDispatchSink.ApplyMotion` that remotes use; it does NOT go
through `_playerController.Motion` at all. DIES in full — the player's
`AnimatedEntity.Sequencer` gets driven through the SAME
`MotionTableDispatchSink``DefaultSink` funnel remotes use (§3).
### 1i. `input.` reads for MOTION (not camera) — full inventory
All in `PlayerMovementController.Update`, all replaced by edge calls
except where noted:
- `input.Forward`/`input.Backward` (`:907-909`, `:1309-1322`) → W press/S
press/release edges.
- `input.StrafeLeft`/`input.StrafeRight` (`:912-914`, `:1328-1337`) →
strafe press/release edges.
- `input.TurnLeft`/`input.TurnRight` (`:917-919`, `:1344-1353`) → turn
press/release edges. **Note**: `input.TurnRight`/`TurnLeft` ALSO drive
Yaw integration directly at `:946-951` — that consumption STAYS (reads
`_motion.InterpretedState.TurnCommand`, unchanged per the plan).
- `input.Run` (`:903`, `:910`, `:915`, `:920`, `:1313`, `:1365`,
`:1445-1446`, `:1502`) → collapses to the Shift edge → `set_hold_run`.
- `input.Jump` (`:1000`) → STAYS as the charge/fire trigger (unchanged
shape, gains the `ChargeJump()` call per 1c).
- `input.MouseDeltaX` (`:952`) → STAYS (camera/Yaw only, never a motion
command — deliberately, per the existing comment on why mouse-turn
generates no wire command).
---
## §2 The edge-detector design
### 2a. Minimal prev-state
The controller needs exactly 6 booleans (one per discrete axis-direction)
plus the existing Shift/Run bool, replacing the current
`_prevForwardCmd`/`_prevSidestepCmd`/`_prevTurnCmd`/`_prevForwardSpeed`/
`_prevRunHold`/`_prevLocalAnimCmd` fields (`:186-191`) which tracked
OUTPUT state for change-detection. The new fields track INPUT state for
edge-detection:
```csharp
private bool _prevForwardHeld; // input.Forward
private bool _prevBackwardHeld; // input.Backward
private bool _prevStrafeLeftHeld;
private bool _prevStrafeRightHeld;
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld; // input.Run (Shift)
```
Six of the seven map 1:1 onto retail's per-axis `RawMotionState` channels
(forward, sidestep, turn — each a single "current command" slot, so
Forward-press-while-Backward-held needs the SAME "switch channel" handling
`ApplyMotion` already does verbatim — no new logic needed, just route
both to the forward channel).
### 2b. Edge → call table
| Edge | Call | MovementParameters |
|---|---|---|
| Forward pressed (was false, now true) | `DoMotion(MotionCommand.WalkForward, p)` | ctor defaults (`new MovementParameters()`) — `Speed=1f` is the retail raw speed (ACE recomputes broadcast speed; D6.2b finding already established this). |
| Forward released (was true, now false), AND Backward not held | `StopMotion(MotionCommand.WalkForward, p)` | ctor defaults. |
| Backward pressed | `DoMotion(MotionCommand.WalkBackward, p)` | ctor defaults (`adjust_motion` applies `BackwardsFactor` — unchanged, already verbatim). |
| Backward released, Forward not held | `StopMotion(MotionCommand.WalkBackward, p)` | ctor defaults. |
| StrafeRight pressed | `DoMotion(MotionCommand.SideStepRight, p)` | ctor defaults. |
| StrafeLeft pressed | `DoMotion(MotionCommand.SideStepLeft, p)` | ctor defaults. |
| Strafe released (both false) | `StopMotion(MotionCommand.SideStepRight, p)` (retail always stops via the RIGHT id per `adjust_motion`'s SideStepLeft→SideStepRight canonicalization — confirmed in `RawMotionState.ApplyMotion`'s switch, which treats `0x6500000f`/`0x65000010` as the same channel) | ctor defaults. |
| TurnRight pressed | `DoMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
| TurnLeft pressed | `DoMotion(MotionCommand.TurnLeft, p)` | ctor defaults. |
| Turn released (both false) | `StopMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
| Shift edge (any transition) | `set_hold_run(input.Run, interrupt: true)` | N/A — direct call, no `MovementParameters`. `interrupt:true` matches the decomp's SECOND caller (`006b33ca`, `arg3=1`, explicitly the "player input/command layer" call site per `r3-motioninterp-decomp.md:1001`) — the FIRST caller (`0058b303`) omits arg3 in the decomp text (ambiguous, likely also non-zero given calling convention defaults per the decomp's own hedge) and is not the player-input site anyway. |
| Jump release (existing charge-fire block) | `_motion.jump(_jumpExtent)` — UNCHANGED call, only gains the `ChargeJump()` call at charge-START (see §1c/§6 R1) | N/A — `jump(float, int)` takes no `MovementParameters`. |
| Auto-walk `DoMotion(Ready)`/`DoMotion(WalkForward)` (`:732`, `:767`) | UNCHANGED — plan explicitly keeps this. | 2-arg compat overload, unchanged. |
**TODO flag (per the task's own instruction)**: the decomp does not show
a `DoMotion`/`StopMotion` call site at the CommandInterpreter boundary —
only `set_hold_run`'s two callers are visible in
`r3-motioninterp-decomp.md` §5c/§8 (`0058b303`/`006b33ca`). The
DoMotion/StopMotion edge calls above are this doc's inference from the
verbatim `DoMotion`/`StopMotion` bodies' own defaulting behavior (both
build a **fresh** `MovementParameters` internally for the
`DoInterpretedMotion`/`StopInterpretedMotion` tail-call regardless of what
the caller passes — see `MotionInterpreter.cs:915`, `:992` — so the
caller-supplied `p`'s only LIVE effects are `CancelMoveTo` (interrupt),
`SetHoldKey`+`HoldKeyToApply` (hold-key push), `Speed`, and
`ModifyRawState`/`ModifyInterpretedState` gates). Ctor defaults
(`CancelMoveTo=true, SetHoldKey=true, ModifyRawState=true,
ModifyInterpretedState=true, Speed=1f, HoldKeyToApply=Invalid`) are the
right choice UNLESS retail's real CommandInterpreter caller passes
`HoldKeyToApply` per-axis (plausible for Run) — **TODO(W6 impl): verify
against a fresh cdb capture of the CommandInterpreter boundary before
implementing**, per the task's own note. This doc marks it a TODO rather
than guessing further, consistent with CLAUDE.md's "do not guess" rule.
### 2c. Why `DoMotion`'s own re-defaulting makes this safe
`DoMotion(motion, p)` (`MotionInterpreter.cs:908-949`) builds `var local =
new MovementParameters()` (fresh re-default) and only carries
`local.Speed = speed` and `local.HoldKeyToApply = p.HoldKeyToApply`
through to `DoInterpretedMotion`. The CALLER's `p` only matters for:
`p.CancelMoveTo` (interrupt-before-dispatch), `p.SetHoldKey` (whether to
call `SetHoldKey` first), `p.ModifyRawState` (whether the raw-state mirror
runs at the end). This means the controller passing ctor-default
`MovementParameters` per edge is not a simplification that loses
information — it is EXACTLY what retail's own `DoMotion` does internally
regardless of caller intent, for every field except the four above. Low
risk.
---
## §3 The player sequencer drive — tracing DefaultSink to the sequencer
### 3a. Call path confirmed present, ONE binding missing
Full trace, verified directly in `MotionInterpreter.cs`:
1. Controller calls `DoMotion(motion, p)` (edge) →
2. `DoMotion``DoInterpretedMotion(motion, local)` (`:943`) →
3. `DoInterpretedMotion(uint, MovementParameters)` (`:2837-2838`) →
`DoInterpretedMotion(motion, p, DefaultSink)` (the sink-parameterized
core) →
4. Inside (`:2850-2947`): `sink?.ApplyMotion(motion, p.Speed)` (`:2884`)
**this is the dispatch to the animation backend**.
Separately, for the CONTINUOUS axes (forward/sidestep/turn re-applied
every physics event, e.g. on `LeaveGround`/`HitGround`/`set_hold_run`):
1. `set_hold_run`/`SetHoldKey`/`LeaveGround`/`HitGround` all call
`apply_current_movement(cancelMoveTo, allowJump)` (`:1404-1417`) →
2. Dual-dispatch gate (A3): `isThePlayer = WeenieObj is null ||
WeenieObj.IsThePlayer()`. Local player: `WeenieObj` is
`PlayerWeenie` (constructed at `PlayerMovementController.cs:354`,
presumably `IsThePlayer() == true` — **needs one-line confirmation in
IWeenieObject/PlayerWeenie, not re-derived here**) AND
`PhysicsObj.LastMoveWasAutonomous` is set `true` at construction
(`PlayerMovementController.cs:360`, with the comment "R3-W6 refines
this per-motion") → routes to `apply_raw_movement(cancelMoveTo,
allowJump)` (the bool-arg overload, `:1443-1450`) →
3. That overload calls `apply_raw_movement(RawState)` (mutates
`InterpretedState` from the interpreter's OWN `RawState` — no longer
an externally-built snapshot, this is the key W6 shift) then
`ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump)` (`:1449`) →
4. `ApplyCurrentMovementInterpreted` (`:1491-1536`): `if (DefaultSink is
not null) { ApplyInterpretedMovement(InterpretedState.CurrentStyle,
DefaultSink, cancelMoveTo, allowJump); return; }` (`:1502-1506`) —
**this IS the full funnel dispatch** (style → forward-or-Falling →
sidestep(-stop) → turn(-stop)), the SAME `ApplyInterpretedMovement`
the inbound remote funnel uses and the SAME dispatch-order the S2a
183-case suite pins.
**CONFIRMED: the call path from an edge-driven `DoMotion`/`set_hold_run`
all the way to `AnimationSequencer.SetCycle`/`PerformMovement` already
exists in Core, fully wired, contingent on ONE thing: `DefaultSink` must
be non-null for the player's `MotionInterpreter`.**
### 3b. The missing binding — confirmed via direct read + subagent cross-check
`MotionInterpreter.cs:1479-1489` doc comment states explicitly: *"Null
(the local player until R3-W6, interp-less entities) → the AP-77
physics-only tail."* Confirmed live: at
`GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), the player's
`_playerController.Motion` gets THREE of the four seams bound
(`RemoveLinkAnimations`, `InitializeMotionTables`,
`CheckForCompletedMotions`) but explicitly NOT `DefaultSink` — inline
comment: *"DefaultSink stays null until R3-W6 (UpdatePlayerAnimation
still drives the player's cycles)."*
Compare against the remote pattern, `EnsureRemoteMotionBindings`
(`GameWindow.cs:4195-4224`), called lazily from `OnLiveMotionUpdated`
(`:4679`) and `OnLiveVectorUpdated`(`:4859`):
```csharp
rm.Sink = new MotionTableDispatchSink(ae.Sequencer)
{
TurnApplied = (turnMotion, turnSpeed) => { /* ObservedOmega seed */ },
TurnStopped = () => rmForSink.ObservedOmega = Vector3.Zero,
};
rm.Motion.DefaultSink = rm.Sink; // <-- MISSING for player
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
```
**W6 action**: at `GameWindow.cs:12987-13000` (`EnterPlayerModeNow`),
construct a `MotionTableDispatchSink(playerSeq)` and bind
`_playerController.Motion.DefaultSink = <that sink>` alongside the three
existing binds. The `TurnApplied`/`TurnStopped` callbacks exist for the
`ObservedOmega` remote dead-reckoning seed — the LOCAL player has no
`ObservedOmega` concept (it's a remote-only field on `RemoteMotion`), so
these two callbacks can be no-ops for the player's sink, OR omitted
entirely if `MotionTableDispatchSink`'s constructor doesn't require them
(confirm the class's actual required-vs-optional members before
implementing — not independently re-verified in this pass beyond the
subagent's citation of `MotionTableDispatchSink.cs:34-72`).
**Where to store the sink instance**: unlike `RemoteMotion` (a wrapper
struct with `.Sink`/`.Motion`/`.ObservedOmega` fields), the player has no
equivalent wrapper — `_playerController` plays that role directly. The
sink instance needs a home: either (a) a new field on
`PlayerMovementController` (parallels `Motion` being exposed there
already), or (b) a `GameWindow`-local field alongside the existing
`_playerController` field. Recommend (a) for symmetry with `Motion`'s
existing exposure pattern (`PlayerMovementController.cs:375`) — a new
`internal MotionTableDispatchSink? Sink { get; set; }` property, set from
`EnterPlayerModeNow`, mirrors `RemoteMotion.Sink` exactly.
### 3c. What this replaces
Once `DefaultSink` is bound, `UpdatePlayerAnimation`'s direct
`ae.Sequencer.SetCycle(...)` call becomes fully redundant — the funnel
dispatch (`ApplyInterpretedMovement``DefaultSink.ApplyMotion`
`MotionTableDispatchSink.ApplyMotion``sequencer.PerformMovement`) now
fires on every edge-driven `DoMotion`/`StopMotion`/`set_hold_run`/
`LeaveGround`/`HitGround` call, exactly mirroring how remotes already
work. The airborne→Falling swap "falls out of `contact_allows_move` +
`apply_current_movement`" per the plan — confirmed: `ApplyInterpretedMovement`
(`:2731-2733`) already has `if (!contact_allows_move(...))
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);` as its
SECOND dispatch (unconditional check every call), so once the player's
`apply_current_movement` reaches this funnel on the LeaveGround edge, the
Falling swap happens automatically with no App-side airborne branch
needed at all — `UpdatePlayerAnimation`'s `:10164-10165` airborne
override becomes dead code, confirming full deletion is correct, not just
plumbing-safe.
---
## §4 GameWindow edit list
| # | Current anchor | What replaces it |
|---|---|---|
| 1 | `UpdatePlayerAnimation` definition, `:10139-10307` | DELETE the method entirely. |
| 2 | `UpdatePlayerAnimation(result)` call, `:8006` | DELETE the call (the tail of the `if (_playerController is not null)` block in `OnUpdate`). |
| 3 | `EnterPlayerModeNow`, binding block `:12987-13000` | ADD the `DefaultSink` bind (§3b) as a 4th line alongside the existing 3 seam binds. |
| 4 | Outbound wire-build block, `:7893-7913` (fresh `RawMotionState` built from `MovementResult` fields) | Read `_playerController.Motion.RawState` directly instead of re-deriving. See §5 for exactly which fields still need `MovementResult` vs which now come from `RawState`. |
| 5 | `ApplyServerRunRate` call site, `OnLiveMotionUpdated` `:4373` (per subagent citation) | UNCHANGED — `ApplyServerRunRate` (`PlayerMovementController.cs:410-414`) already calls `_motion.apply_current_movement(false, false)`, which after 3b's bind now ALSO drives the sequencer via `DefaultSink` (a currently-inert call becomes live) — no GameWindow-side edit needed, but flag as a **behavior change**, not just plumbing (see §6 R2). |
| 6 | JumpAction wire-build block, `:7984-8002` | UNCHANGED — still reads `result.JumpExtent`/`result.JumpVelocity` from `MovementResult` (§5 keeps these fields; the jump path is not touched by the DoMotion/StopMotion edge redesign). |
| 7 | `OnLiveVectorUpdated`'s remote-jump `LeaveGround()`+`DefaultSink` pattern, `:4845-4864` | UNCHANGED — this is the ALREADY-MIGRATED reference pattern (W4/J19 closed) that item 3 mirrors for the player. Cited here only as the proof-of-pattern, not an edit site. |
Two GameWindow items the plan names that turned out to be **already
resolved, not pending W6 work** (found this session, not anticipated by
the plan text):
- **`skipTransitionLink`**: confirmed via repo-wide grep — zero
occurrences in any production `.cs` file. Fully deleted in W4. The
plan's W6 bullet listing this as something to delete is stale; no
action needed.
- **`ACDREAM_ANIM_SPEED_SCALE`/#45 sidestep factor**: both live entirely
inside the doomed `UpdatePlayerAnimation` (`:10258-10295`) — they die
WITH the method, they don't need a separate "move into the
CMotionTable-driven speedMod path" step, because there is no equivalent
concept upstream in `MotionTableDispatchSink`/`AnimationSequencer.
PerformMovement` today. See §6 R3 — this is a real behavior-loss risk,
not a mechanical relocation, and needs a decision before deletion.
---
## §5 Wire-parity plan
### 5a. Current outbound flow (pre-W6)
`GameWindow.cs:7893-7913` builds a FRESH `RawMotionState` from
`MovementResult.ForwardCommand`/`SidestepCommand`/`TurnCommand`/
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`, then passes it to
`MoveToState.Build(...)` (`:7916`), which internally calls
`RawMotionStatePacker.Pack(w, rawMotionState)`
(`MoveToState.cs:80`).
### 5b. Post-W6 target flow
`_playerController.Motion.RawState` is, by construction, the SAME
`RawMotionState` instance the edge-driven `DoMotion`/`StopMotion` calls
have been mutating via `ApplyMotion`/`RemoveMotion` all frame (per J2's
W1 fold: `RawState` IS the packer-compatible type, action FIFO included).
GameWindow's outbound block should read directly from
`_playerController.Motion.RawState` instead of reconstructing one.
**Field-by-field disposition**:
| `RawMotionState` field | Source post-W6 | Notes |
|---|---|---|
| `CurrentHoldKey` | `RawState.CurrentHoldKey`, set by `set_hold_run` edge | Was `result.IsRunning ? Run : None` — now the interpreter's own tracked value. |
| `ForwardCommand`/`ForwardHoldKey`/`ForwardSpeed` | `RawState.*`, set by `ApplyMotion`/`RemoveMotion` on the Forward/Backward edges | Was `result.ForwardCommand ?? Default`. |
| `SidestepCommand`/`SidestepHoldKey`/`SidestepSpeed` | `RawState.*` | Was `result.SidestepCommand ?? Default`. |
| `TurnCommand`/`TurnHoldKey`/`TurnSpeed` | `RawState.*` | Was `result.TurnCommand ?? Default`. |
| `Actions` | `RawState.Actions` | Was ALWAYS EMPTY pre-W1 (TS-24); post-W1 populated by `ApplyMotion`'s action-class branch — this is a genuine NEW behavior surfacing on the wire once W6 reads live `RawState` instead of a `MovementResult`-rebuilt empty-actions snapshot. Flag in §6 R5. |
**`MovementResult` fields that SURVIVE** (still needed by GameWindow for
non-wire purposes): `Position`, `RenderPosition`, `CellId`, `IsOnGround`
(camera/HUD), `JustLanded` (landing SFX/anim triggers if any exist
outside `UpdatePlayerAnimation` — verify no other consumer before
deleting used-only-by-UpdatePlayerAnimation fields), `JumpExtent`/
`JumpVelocity` (JumpAction wire build, item 6 in §4, untouched by this
redesign since jump keeps its existing dedicated path).
**`MovementResult` fields that DIE**: `LocalAnimationCommand`,
`LocalAnimationSpeed` (§1d). **Candidates for deletion pending §5c
decision**: `ForwardCommand`/`SidestepCommand`/`TurnCommand`/
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`/
`MotionStateChanged` — these become redundant with `RawState` reads, but
deleting them is a larger surface change to `MovementResult`'s consumers
(the golden-byte tests test `RawMotionStatePacker`/`MoveToState`
directly, NOT `MovementResult`, so they're unaffected either way — see
5d). Recommend KEEPING these fields in `MovementResult` for this W6 slice
(mark `[Obsolete]` or comment "now diagnostic-only, GameWindow reads
RawState") rather than deleting, to keep the W6 diff scoped to the
motion-drive mechanism, not a `MovementResult` API redesign — a follow-up
cleanup commit can trim the struct once nothing reads it.
### 5c. `MotionStateChanged` gate — needs a replacement signal
`GameWindow.cs:7874` gates the whole outbound MoveToState build on
`result.MotionStateChanged && !_playerController.IsServerAutoWalking`.
Post-W6, `MovementResult.MotionStateChanged` (computed from the DYING
`_prevForwardCmd`/etc. output-comparison block, §1d) needs a replacement.
**Recommend**: derive change-detection from the edge table itself — ANY
edge fired this frame (forward/backward/strafe/turn/run edges, §2a) is
definitionally a state change; expose a `bool MotionChangedThisFrame`
computed from `_prevXHeld != inputXHeld` ORed across all axes, computed
in the SAME `Update` call, replacing the output-comparison approach with
an input-edge approach. This is a strictly simpler equivalent (retail's
CommandInterpreter only calls `DoMotion`/`StopMotion` ON edges in the
first place, so "an edge fired" and "state changed" are the same
predicate by construction — no separate comparison needed). Confirm this
doesn't regress the `runHold`-without-directional-edge case (Shift toggle
while already moving) — the edge table's Shift-edge row already covers
this (`set_hold_run` fires independently of the directional edges), so
OR-ing in the Shift edge too closes that case.
### 5d. Golden-byte tests — confirmed unaffected
`RawMotionStatePackTests.cs`, `MoveToStateGoldenTests.cs`,
`MoveToStateTests.cs`, `AutonomousPositionTests.cs`, `JumpActionTests.cs`,
`PositionPackTests.cs` (all under
`tests/AcDream.Core.Net.Tests/Messages/`) construct `RawMotionState`/call
`MoveToState.Build` with EXPLICIT field values directly — none reference
`PlayerMovementController` or `MovementResult`. They pin
`RawMotionStatePacker`'s bit-packing contract, which W6 does not touch
(only WHERE the packed values are sourced from changes). These stay green
as a structural guarantee, not something W6 needs to specifically verify
beyond "still compiles, still passes" — but a NEW test is needed for the
end-to-end path: build a `RawMotionState` via live `DoMotion`/`StopMotion`
edges, feed it to `RawMotionStatePacker.Pack`, and diff against the SAME
byte sequence the pre-cutover `MovementResult`-rebuilt path produced for
an identical input script (see §7).
---
## §6 Risk list
**R1 — `ChargeJump()` never called (BLOCKER-adjacent discovery, not in
the plan text).** Confirmed via grep: `MotionInterpreter.ChargeJump()`
has zero production call sites — only `MotionInterpreterJumpFamilyTests.cs`
calls it. `StandingLongJump` therefore never arms for ANY entity today
(local or remote), despite W3 having fully ported the mechanism. W6's
jump-charge block (§1c) MUST add a `ChargeJump()` call when charge begins
(mirroring `input.Jump && !_jumpCharging` → also call `_motion.ChargeJump()`,
checking its `WeenieError` return the same way the fire-path already
checks `jump()`'s). Without this, the "standing long-jump" feature stays
silently dead post-cutover — same bug, just no longer excusable as "W3
hasn't wired the call site yet." **Recommendation: treat as in-scope for
W6, not a separate follow-up** — it's the SAME jump-charge block W6 is
already editing to add `ChargeJump`'s sibling gate calls.
**R2 — `ApplyServerRunRate`'s `apply_current_movement` call goes from
inert to live.** Pre-W6, `ApplyServerRunRate` (`PlayerMovementController.cs:
410-414`) calls `_motion.apply_current_movement(false, false)`, but
`DefaultSink` is null so `ApplyCurrentMovementInterpreted` falls through
to the AP-77 physics-only tail (a `get_state_velocity``set_local_velocity`
write, no sequencer touch). Post-W6 (once `DefaultSink` is bound, §3b),
the SAME call now ALSO dispatches through the funnel
(`ApplyInterpretedMovement``SetCycle`) on every server RunRate echo —
this could fire a redundant/premature `SetCycle` call on every
`UpdateMotion (0xF61C)` echo for the player's own guid, independent of
any local edge. **Recommendation**: verify this doesn't cause visible
cycle-restart flicker (retail's real `apply_current_movement` call here IS
supposed to re-dispatch — this may be CORRECT retail behavior surfacing
for the first time — but it's a genuine visual-risk item for the "ONE
user visual pass" acceptance test, not a mechanical no-op).
**R3 — `#45` sidestep factor + `ACDREAM_ANIM_SPEED_SCALE` have no
upstream equivalent.** Per §4, both die with `UpdatePlayerAnimation`
because `MotionTableDispatchSink.ApplyMotion`/`AnimationSequencer.
PerformMovement` (the remote path, now shared) has no known
sidestep-specific speed-scale multiplier or global visual-pacing knob.
Two sub-risks: (a) sidestep animation pacing may visibly change (the
1.248× factor matched ACE's `BroadcastMovement` wire formula — losing it
means the sidestep CYCLE plays at `SidestepAnimSpeed`-derived pace instead
of the wire-matched pace); (b) `ACDREAM_ANIM_SPEED_SCALE` (a debug/tuning
knob) simply stops having any effect for the local player post-cutover
(remotes never had it either, so this makes local and remote consistent —
arguably correct, but a functionality LOSS for whoever uses that env var).
**Recommendation**: confirm with the user whether (a) is an
expected-diff (remotes today do NOT get the 1.248× factor either — so
post-cutover the LOCAL player's sidestep pacing becomes consistent with
how remotes have always looked, which may in fact be the retail-faithful
answer the #45 comment's "matching ACE's wire formula" was compensating
for a DIFFERENT bug in) before deleting; document as an expected-diff in
the W6 commit either way, per the plan's own instruction ("expected-diff
annotations required").
**R4 — `DriveServerAutoWalk`'s `DoMotion(Ready)` at `:732` is not named
in the plan's W6 SetPosition-only StopCompletely note.** §1e flags this
textual sibling. Auto-walk survives R3 verbatim per the KEEP LIST, so
changing `:732` is OUT of scope — but a future reader diffing "every
`DoMotion(Ready)` call site" against the plan's single cited line (`:825`
old numbering) could reasonably assume both change. **Recommendation**:
leave `:732` untouched, but add a one-line comment there cross-referencing
this doc so the distinction is explicit at the code site, not just here.
**R5 — `RawState.Actions` starts reaching the wire for the first time.**
Per §5b, reading live `RawState` instead of a `MovementResult`-rebuilt
snapshot means the `Actions` FIFO (populated since W1/W2 by
`ApplyMotion`'s action-class branch, e.g. `motion & 0x10000000` bit —
action-class motions like emotes/attacks IF the local player ever calls
`DoMotion` with an action-class id) starts appearing in the packed
`MoveToState` bytes where it was previously always empty (TS-24). If
nothing in the current player-input path ever issues an action-class
`DoMotion` call, this is a no-op in practice for W6's scope (movement axes
only) — but flag it so a future action/emote wiring phase doesn't get
blindsided by "wait, did this already work?"
**R6 — `IWeenieObject.IsThePlayer()` for `PlayerWeenie` not
independently re-verified this session.** §3a's dispatch-path trace
ASSUMES `PlayerWeenie.IsThePlayer() == true` (required for
`apply_current_movement`'s A3 gate to route to `apply_raw_movement`,
which is the path that reaches `DefaultSink`). This is almost certainly
already correct (W3 added `IsThePlayer()` to `IWeenieObject` specifically
for this dispatch, per J5's gap list), but a W6 implementer should grep
`PlayerWeenie.cs` for the override before writing code, not assume from
this doc alone.
---
## §7 Test plan
### 7a. New unit tests — edge table
Add to `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`
(or a new `PlayerMovementControllerEdgeTests.cs` alongside it, matching
the existing per-concern file split seen in
`DispatcherToMovementIntegrationTests.cs`):
- Press-W → `_motion.RawState.ForwardCommand == WalkForward` after one
`Update` call (was: never directly observable pre-W6 since the raw
state was an ephemeral per-frame local; post-W6 it's the persistent
field — this test is only possible/meaningful after the cutover).
- Press-W, hold 3 frames, release → `RawState.ForwardCommand ==
MotionCommand.Ready` after release frame (StopMotion fired).
- Press-W then press-S same frame → forward channel ends on S (or
whichever wins per `ApplyMotion`'s single-channel-per-axis semantics —
pin the actual retail-faithful tie-break, likely "last processed wins"
given the controller processes Forward before Backward in its own
`MovementInput` field order — assert whichever the implementation
produces, don't guess a preference here).
- Shift press while W held → `RawState.CurrentHoldKey == Run` +
`set_hold_run`'s internal `apply_current_movement` fired (observable
via a velocity change or a `DefaultSink` mock's call count once 3b
lands).
Shift press while NOT moving → `CurrentHoldKey == Run` still updates
(retail's `set_hold_run` doesn't gate on movement) but no velocity
visible change.
- Redundant Shift-held-while-already-Run (two consecutive `Update` calls
with `input.Run=true`) → `set_hold_run`'s XOR guard no-ops on the
second call (assert via a call-counting mock on
`apply_current_movement`'s side effect, e.g. `DefaultSink.ApplyMotion`
invocation count stays flat).
- Jump charge-then-release still produces `WeenieError.None` from
`jump()` AND (new) `ChargeJump()` was called at charge-start with a
`StandingLongJump` arm when idle+grounded (R1's fix, needs its own
assertion — grounded+idle+charge+release → `StandingLongJump == true`
observable via `_motion.StandingLongJump` before the release clears it,
or via the funnel's state-only dispatch branch firing).
- SetPosition (teleport) → `RawState.ForwardCommand == Ready` AND
`PendingMotions` gained the A9 snapshot node (StopCompletely's
`AddToQueue` call) — this is a NEW observable side effect vs the old
`DoMotion(Ready)` call, worth a dedicated assertion.
### 7b. Existing tests needing re-pin
Per the tests-agent's findings:
- **`PlayerMovementControllerTests.cs`** (15 facts, 29 `Update` calls) —
the two jump tests (`Update_JumpOnFlatTerrain_BecomesAirborne`,
`Update_AirborneFrames_ZRiseThenFalls`) are HIGH risk for needing
re-pin: they exercise the exact charge→release block §1c/R1 touches.
Re-run after the `ChargeJump()` addition — if `StandingLongJump` arms
during a stationary charge-then-release-while-still-idle scenario these
tests use, a NEW dispatch path (`DispatchInterpretedMotion(Ready)` +
`DispatchStopInterpretedMotion(SideStepRight)` per
`ApplyInterpretedMovement`'s StandingLongJump branch) fires that didn't
before — verify it doesn't change `IsAirborne`/`VerticalVelocity`/
`Position.Z` assertions (it shouldn't, since that branch is state-only
no-dispatch-to-velocity per J6, but MUST be checked, not assumed).
- **`DispatcherToMovementIntegrationTests.cs`** (6 facts) — asserts
dispatcher-built `MovementInput` produces IDENTICAL results to
hand-built input. Should stay green unchanged (it exercises the
`MovementInput``Update` boundary, not the internals W6 rewrites) —
but re-run explicitly since it's the integration seam most likely to
surface an edge-detection regression (e.g. if a test builds two
DIFFERENT `MovementInput` values across two `Update` calls without the
edge-detector's `_prevXHeld` fields being exercised the same way a
real per-frame loop would).
- **`CellarUpTrajectoryReplayTests.cs`**'s
`IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` — a
600-frame bit-stability regression guard with NO input held. Should be
UNAFFECTED (no edges ever fire), but it's cited in memory
(`feedback_render_perf_measurement.md`-adjacent class of test) as a
historically fragile guard — run it explicitly, don't assume "no input
means no risk" given this test exists specifically because a prior
change broke exactly this "nothing should happen when nothing is
pressed" invariant.
### 7c. Pre-cutover trace capture (plan-mandated, not yet done)
Per the plan's Fixture source line: "pre-cutover recorded traces +
golden-byte + suite." This doc does not capture the trace (out of
research-only scope) but flags that the W6 IMPLEMENTATION session must
run `ACDREAM_DUMP_MOTION=1` (or the equivalent `SetCycle`/dispatch trace
capture) against the CURRENT (pre-W6) code first, save the log, THEN
implement the cutover, THEN replay the same input script and diff — this
is a sequencing dependency on the implementation session, not something
this research pass can pre-stage without running the live client.
### 7d. Suites that must stay green throughout, unmodified
`RetailObserverTraceConformanceTests.cs` (184-case funnel dispatch-order
suite) + `MotionInterpreterFunnelTests.cs` (13 facts, synthetic
action-class supplement) — both exercise the INBOUND remote funnel path
(`DispatchInterpretedMotion`/`MoveToInterpretedState`), a sibling path to
what W6 touches. Should require zero changes; green throughout is the
signal that W6's edits to the SHARED `MotionInterpreter` machinery
(`DoMotion`/`PerformMovement`/`apply_current_movement`) didn't perturb
the remote dispatch order. All six golden-byte packer test files (§5d)
likewise stay green unmodified.
---
## Anchor summary (file:line, this session, HEAD `e214acdf`)
- `src/AcDream.App/Input/PlayerMovementController.cs` — full file read,
1547 lines.
- `src/AcDream.App/Rendering/GameWindow.cs``UpdatePlayerAnimation`
`:10139-10307`; caller `:8006`; `ApplyServerRunRate` call `:4373`;
JumpAction block `:7984-8002`; outbound RawMotionState build
`:7893-7913`; `EnterPlayerModeNow` binding block `:12987-13000`;
`EnsureRemoteMotionBindings` `:4195-4224`; `OnLiveVectorUpdated`
LeaveGround pattern `:4845-4864`.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — full file read, 3083
lines; key anchors: `DoMotion` `:856-949`; `StopMotion` `:958-1008`;
`StopCompletely` `:1054-1084`; `apply_raw_movement` (2 overloads)
`:1346-1371`, `:1443-1450`; `apply_current_movement` `:1404-1417`;
`DefaultSink` property `:1489`; `ApplyCurrentMovementInterpreted`
`:1491-1536`; `ChargeJump` `:1765-1789`; `jump` `:1822-1839`;
`LeaveGround` `:2310-2330`; `HitGround` `:2361-2375`;
`EnterDefaultState` `:2415-2427`; `set_hold_run` `:2453-2463`;
`SetHoldKey` `:2506-2525`; `MotionDone` `:2132-2154`;
`ApplyInterpretedMovement` `:2715-2762`; `DoInterpretedMotion`
`:2837-2948`.
- `src/AcDream.Core/Physics/RawMotionState.cs` — full file, `ApplyMotion`
`:208-273`, `RemoveMotion` `:293-321`.
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — full file,
fields `:84-186`.

View file

@ -0,0 +1,165 @@
# L.2g S2 — inbound CMotionInterp funnel: verbatim pseudocode
Date: 2026-07-02. Oracle: named decomp (line refs into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`) + the LIVE cdb trace
of a retail observer (`l2g-observer-trace.log`, breakpoint script
`tools/cdb/l2g-observer.cdb`) which confirmed the exact runtime chain and
per-UM `DoInterpretedMotion` order: **style → forward → sidestep(-stop) →
turn(-stop)**, applied wholesale the tick the message arrives.
Empty-UM semantics (settled): `InterpretedMotionState::UnPack` (0x0051f400,
294360) decodes absent fields to ctor defaults — style `0x8000003D`, forward
`0x41000003 Ready`, speeds `1.0`, sidestep/turn `0` — so a flags=0 UM is a
retail-verbatim FULL STOP. No special-casing. (The wire varies with the
DRIVER's client state; both explicit-walk and empty variants are handled by
the same wholesale apply. `RawMotionState::Pack` 0x0051ed10 confirmed pure
static-default-diff — outbound L.2b port stays as-is.)
## 1. MovementManager::unpack_movement — case 0 (0x00524440, 300563)
```
unpack_movement(blob):
if minterp == null or physics_obj == null: return 0
physics_obj.interrupt_current_movement()
physics_obj.unstick_from_object()
u16 header = read_u16() # low byte: movement type; high byte: motionFlags
u16 styleIdx = read_u16() # outer style, command_ids[] index
style = command_ids[styleIdx]
if minterp.get_current_style() != style: # "GetPinVersion" in BN
minterp.DoMotion(style, default_params) # style applied ONLY on change
switch header.low_byte:
case 0: # InterpretedMotionState
ims = InterpretedMotionState() # ctor defaults (see above)
ims.UnPack(blob) # absent fields keep defaults
stickyGuid = (header & 0x100) ? read_u32() : 0
MovementManager.move_to_interpreted_state(ims)
if stickyGuid: physics_obj.stick_to_object(stickyGuid)
minterp.standing_longjump = (header & 0x200)
return 1
case 6/7: MoveTo… (existing acdream path, keep)
case 8/9: TurnTo… (S6)
default: return 0
```
## 2. CMotionInterp::move_to_interpreted_state (0x005289c0, 305936)
```
move_to_interpreted_state(ims):
if physics_obj == null: return 0
raw_state.current_style = ims.current_style
physics_obj.interrupt_current_movement()
jumpAllowed = motion_allows_jump(interpreted_state.forward_command) # OLD state!
interpreted_state.copy_movement_from(ims) # FLAT overwrite (0x0051e750)
apply_current_movement(force=1, jumpAllowed)
for action in ims.actions: # MotionItem list
# 15-bit wraparound stamp gate vs server_action_stamp (305953-305971)
if newer_15bit(action.stamp, server_action_stamp):
if weenie is player and action.autonomous: skip # local echo guard
server_action_stamp = action.stamp
DoInterpretedMotion(action.command, params(speed=action.speed))
return 1
```
## 3. CMotionInterp::apply_current_movement (0x00528870, 305838)
```
apply_current_movement(force, jumpAllowed):
if physics_obj == null or !initted: return
if (weenie==null or weenie.IsThePlayer()) and physics_obj.movement_is_autonomous():
apply_raw_movement(force, jumpAllowed) # LOCAL player (already ported, D6)
else:
apply_interpreted_movement(force, jumpAllowed) # REMOTES — this port
```
## 4. CMotionInterp::apply_interpreted_movement (0x00528600, 305713)
```
apply_interpreted_movement(force, jumpAllowed):
if physics_obj == null: return
if interpreted_state.forward_command == RunForward (0x44000007):
my_run_rate = interpreted_state.forward_speed # cache server run rate
DoInterpretedMotion(interpreted_state.current_style, {}) # stance
if !contact_allows_move(interpreted_state.forward_command):
DoInterpretedMotion(0x40000015 Falling, {})
elif standing_longjump:
DoInterpretedMotion(0x41000003 Ready, {})
StopInterpretedMotion(0x6500000F SideStep…, {})
else:
DoInterpretedMotion(interpreted_state.forward_command,
{speed: interpreted_state.forward_speed})
if interpreted_state.sidestep_command == 0:
StopInterpretedMotion(0x6500000F, {})
else:
DoInterpretedMotion(interpreted_state.sidestep_command,
{speed: interpreted_state.sidestep_speed})
if interpreted_state.turn_command != 0:
DoInterpretedMotion(interpreted_state.turn_command,
{speed: interpreted_state.turn_speed})
return # early — no idle-stop this call
if StopInterpretedMotion(0x6500000D Turn…, {}) == 0:
add_to_queue(ctx=0, Ready, tick) # idle bookkeeping (S3 wires fully)
```
Live-trace confirmation (actor minterp 18e8b0f8): per UM exactly
`[DIM] 0x8000003D` then `[DIM] <fwd>` (0x45000005 / 0x41000003 / 0x44000007)
then sidestep/turn stops (0x6500000F / 0x6500000D) — order verbatim.
## 5. CMotionInterp::DoInterpretedMotion (0x00528360, 305575)
```
DoInterpretedMotion(motion, params):
if physics_obj == null: return 8
if contact_allows_move(motion):
if standing_longjump and motion in jump-set: goto apply_only
if motion == 0x40000011: physics_obj.RemoveLinkAnimations()
result = physics_obj.DoInterpretedMotion(motion, params)
# → CPartArray → MotionTableManager::PerformMovement
# → CMotionTable::GetObjectSequence ≙ AnimationSequencer.SetCycle
if result == 0:
add_to_queue(params.context_id, motion, jumpAllowed) # pending_motions (S3)
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
elif (motion & 0x10000000) == 0:
apply_only:
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
result = 0
else:
result = 0x24
if physics_obj != null and physics_obj.cell == 0:
physics_obj.RemoveLinkAnimations()
return result
```
## acdream mapping (surgical)
- **New in `MotionInterpreter` (Core.Physics):** `MoveToInterpretedState(ims)`,
`ApplyInterpretedMovement()`, using the EXISTING `DoInterpretedMotion` /
`StopInterpretedMotion` (extended to retail semantics above) with the
sequencer as the `GetObjectSequence` backend. `InterpretedMotionStateData`
= a plain struct mirroring the ctor defaults; built from
`UpdateMotion.Parsed` (absent wire fields → defaults — the parser already
yields nullables; the CONVERSION applies the defaults).
- **`OnLiveMotionUpdated` remote SubState branch collapses** to: build ims →
`remoteMot.Motion.MoveToInterpretedState(ims, sequencer)`. Style-on-change
at the unpack level (today's `fullStyle` preserve-current logic maps to
`get_current_style()` compare). PRESERVE deliberately: K-fix17 airborne
cycle guard, HasCycle fallback chain, MoveTo (case 6/7) seeding, overlay
(Action-class) routing — these live in the DIM backend, not deleted.
- **Stop** rides the same path: empty UM → ims defaults → fwd=Ready →
apply → sequencer Ready + `get_state_velocity`→0 → body velocity zero. The
acdream-invented 300 ms stop-detection window + UP-near-zero StopCompletely
become dead code to remove in the same slice (DEV-3).
- **`my_run_rate` caching** per remote (apply_interpreted_movement head).
- Tests first: fake-sequencer call-order tests per UM shape (empty, walk,
run@2.85, run+turn, action list, stale action stamp), then live smoke.
## Live-trace answer to the S0-open question
Retail observer applies EVERY accepted UM wholesale — including empties as
full stops. "Correct behavior" (user-verified during the trace) comes from
this + the chase, not from any inference. acdream's S2 target = exactly this.

View file

@ -0,0 +1,173 @@
# Phase R handoff — fresh-session entry point (2026-07-03)
> **POSTSCRIPT (2026-07-03, later session): R4 is COMPLETE.** V5 (local-player
> cutover) shipped as `b3decdfa` — P1 gate ported verbatim, B.6 auto-walk
> deleted, TS-36 bound, run-rate re-anchored to PD skills + mt-6/7
> my_run_rate with NO AD row (the contingency wasn't needed — see the
> V0-pins.md P1 postscript), MoveToComplete seam widened to natural
> completion for AD-27, plus three adversarial-review fixes (remote
> HitGround relay, mt-8 wire_heading degrade, remote curTime clock). V6
> register/roadmap/memory sweep shipped in the follow-up commit. The V4
> smoke log was analyzed CLEAN (mt-6/8 events all dispatched, arrival
> stop transitions, zero exceptions/stalls). Suite: 3,956 green.
> REMAINING: the ONE combined R2+R3+R4 user visual pass, then R5.
> The sections below are the historical entry state, kept verbatim.
>
> **POSTSCRIPT 2 (2026-07-03, the live verify session):** four live bugs
> found+fixed during the user's pass, each root-caused (trail:
> `c2dc1a88` StopCompletely's missing StopCompletely_Internal animation
> dispatch orphaned pending_motions → wait-for-anims wedge; `24569fd2`
> login SetPosition ran before the sink bind → one immortal orphan node;
> `006cf659` remote-player movetos never ticked (glide) + door UMs
> dropped (no RemoteMotion for UP-less entities); `350fb5e3` the
> detached-object guard's ObjCellId proxy stripped ALL remote transition
> links → door-swing snap, register TS-40). CONFIRMED WORKING by user:
> local use-walk + door open + door swing + remote close-range walk.
> STILL OPEN: ISSUES **#160** (remote run-anim pace vs ACE movement
> speed), **#161** (remote landing pose — retest first, TS-40 may have
> fixed it), **#162** (glide-class ACE autonomous-reflection cancels —
> adjudication pending the user's retail-vs-retail A/B; adaptation needs
> explicit approval), **#163** (strip the temp diagnostics).
**Read this first, then the plan of record**
(`docs/plans/2026-07-02-retail-motion-animation-rewrite.md`). This session
ran the R2→R4 arc of the Phase R mandate; the fresh session picks up at
**R4-V4 smoke verification → R4-V5**.
## The mandate (user, standing)
Total overhaul of movement + animation as a VERBATIM retail port — all
movement, inbound and outbound, all animation, for players/NPCs/monsters.
No guessing, no approximations, no bandaids. The user cancelled the /loop
self-scheduling near the end of this session — **work continues on user
prompts, not self-scheduled wakeups**, but the drive-autonomously rules in
CLAUDE.md still apply within a turn.
## State at handoff (worktree `vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`)
- Tree: CLEAN at `7016b26c` (only `v4-smoke.log` untracked — session
scratch, delete freely). Full suite green: **3,948 tests**
(385 Net + 425 UI + 713 App + 2,425 Core, +4 pre-existing skips).
- **R1 ✅ R2 ✅ R3 ✅ shipped** (trail + commit SHAs recorded in the plan
doc's stage entries). **R4: V0V4 shipped, V5+V6 remain.**
- A smoke client was launched with the V4 build (`ACDREAM_DUMP_MOTION=1`,
log `v4-smoke.log` in the worktree root, UTF-16) — **the user had NOT yet
reported an NPC-behavior verdict at handoff time.** First order of
business: get that verdict (or relaunch and ask). Watch items: NPC
chase/wander legs, turn-to-face, melee-range stop distance (arrival is
now retail cylinder distance — the old max() gate is gone).
## What just landed (this session, newest first)
| Commit | What |
|---|---|
| `7016b26c` | **R4-V4** remote cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED; P4 TargetTracker adapter; retail unpack_movement dispatch (head-interrupt every UM; mt 69 never touch the interpreted funnel) |
| `a144e873` | **R4-V3** wire: mt 8/9 parsing (TurnToPathData), raw bitfield surfaced, mt-0 sticky trailer |
| `addc8e97` | **R4-V2** the verbatim MoveToManager (33 members, 101 tests; seams listed in its final report — ctor takes the interp + 14 providers; the identity-quaternion TDD catch) |
| `e0d2492c` | **R4-V1** command-selection family (GetCommand + CanCharge fast-path; MoveToMath incl. the Ghidra-confirmed heading_diff mirror) |
| `386b1ce5` | **R4-V0** pins P1P7 (see below) |
| `988304e1` | R4 research base (`docs/research/2026-07-03-r4-moveto/` — decomp, ACE cross-ref, port plan) |
| `30115d96` | **W6b fix** — the "press W and stop instantly" live regression (see gotchas) |
| `fb7beb70``8eff3978` | R3 W0W7 + R2 Q0Q6 (see the plan doc's stage trails) |
## NEXT: R4-V5 — LOCAL PLAYER cutover (the delicate one)
Spec: `docs/research/2026-07-03-r4-moveto/r4-port-plan.md` §3 V5.
**ONE commit, GameWindow + PlayerMovementController, do NOT fan out**
(feedback_dont_parallelize_coupled_plan_slices). Shape:
- The player gets a MoveToManager bound like remotes (the
EnsureRemoteMotionBindings block at GameWindow is the reference wiring;
the player's Motion/seams already exist from R3 — DefaultSink is bound,
`_playerController.Motion` exposed).
- **B.6 auto-walk DELETED**: `DriveServerAutoWalk` + `BeginServerAutoWalk`
+ the `_autoWalk*` fields + the local mt-6 branch in GameWindow
(`BeginServerAutoWalk` call site) + the two relocated constants
(`AutoWalkArrivalEpsilon`, `AutoWalkTurnRateFor` — marked V5-scope in the
controller).
- **P1 pin applies here**: port the missing tail of retail's
`CPhysics::SetObjectMovement` gate — DROP any 0xF74C event whose wire
`IsAutonomous` byte is set when guid == local player (parsed at
`UpdateMotion.cs` isAutonomous, relayed by WorldSession, consumed
NOWHERE yet). That's what makes retail's unconditional head-interrupt
safe against ACE's mt-0 echo. `V0-pins.md` P1 has the full adjudication +
the LoseControlToServer note. This RETIRES the informal
"don't cancel on non-MoveTo" comment block in the local UM branch.
- TS-36 retires (InterruptCurrentMovement bound for the player);
AD-26 retires; AD-27/AP-23 re-anchor.
- Movement keys auto-cancel a server MoveTo (retail: the CommandInterpreter
DoMotion's cancel bit → interrupt → CancelMoveTo — the edge-driven W6
input already calls DoMotion with ctor-default params whose CancelMoveTo
is TRUE, so this may fall out for free — verify, don't assume).
- Tests: the V2 harness pattern for the player-side bind; the W6
edge-driven regression suite (`W6EdgeDrivenMovementTests.cs`) is the
template for controller-level tests with a real sink.
- Then **V6**: register/roadmap/plan/memory sweep (V6 spec in the plan).
After V6, R4 is done → R5 (MovementSystem facade + StickyManager +
ConstraintManager + TargetManager full port), R6 (per-tick
UpdateObjectInternal order — retires the 300ms stop window, PlanFromVelocity
AP-80, ObservedOmega AP-76, AP-75/AP-77 velocity adaptations), R7
(outbound cadence TS-33), R8 (audit + final visual pass).
## Pending user verifications (visual passes)
1. **V4 smoke verdict** (see above — NPC movement).
2. **R2+R3 combined visual pass** (task #9): jump instant-Falling engage,
walk-off-ledge momentum (NEW behavior), running-in-circles blend, stop
settle, strafe pacing (EXPECTED-DIFF: #45's 1.248× factor retired —
local now matches remotes), retail-observer view (no rubber-band).
Partially confirmed ("works a lot better now") after the W6b fix.
## Load-bearing gotchas from this session (do not relearn these)
1. **BN hoisted-register artifact class** (caused the W6b live bug): the
pseudo-C renders pre-call register caches as post-call field reads.
`ApplyInterpretedMovement` must ENTRY-CACHE the axis values — the style
dispatch's `ApplyMotion(style)` resets forward to Ready (verbatim,
raw 0051ea6c) and retail self-heals via the cached fwd re-apply. The
183-case suite can NOT catch sink-result-dependent bugs: its
RecordingSink's bool return doesn't mirror the real
MotionTableDispatchSink (style dispatches return TRUE → the resetting
state-write runs). Regression suite: `W6EdgeDrivenMovementTests.cs`.
2. **The dispatch RESULT gates queue+state writes** (W5 discovery):
`IInterpretedMotionSink.ApplyMotion/StopMotion` return bool for this
reason — documented on the interface.
3. **Retail's echo discriminator is the wire autonomous byte** (V0 P1):
not a workaround — `CPhysics::SetObjectMovement` 0x00509690's final
branch. Port it in V5, don't invent gating.
4. **MoveToManager entry points never drain pending_actions** — re-issues
MUST route through `PerformMovement` (V2 finding, tested).
5. **`default(Quaternion)` is zero, not identity** — heading math reads
90° off a zero quat (V2 TDD catch; `IdentityPosition` constant).
6. **fail_progress_count is WRITE-ONLY in retail** — there is no MoveTo
give-up threshold; do not invent one.
7. **Delegation pattern that works**: sonnet implementers against
committed pinned specs (research → pins with adversarial verify →
implement → orchestrator independently verifies scope + suite +
spot-checks against the raw decomp → commit). GameWindow/controller
coupled surgery stays with the orchestrator. Workflow tool for
research fan-outs (r3/r4 extraction + pin workflows are the templates).
8. **py (not python) exists on this box**; logs from Tee-Object are
UTF-16; heredocs with quotes inside python strings break — write
scripts to the scratchpad with the Write tool and run `py file.py`.
9. **Ghidra MCP** (port 8080/8081, needs the user to open patchmem.gpr):
used live this session for heading_diff/get_desired_heading
(`ghidra-confirmations.md`). Ask the user to open it when a BN garble
blocks a pin.
10. **ACE session care**: graceful client close (CloseMainWindow) or ACE
holds the session ~3 min (exit 29 on relaunch). Don't kill clients
the user launched — ask.
## Key file map (R4 additions)
- `src/AcDream.Core/Physics/Motion/MoveToManager.cs` + `MoveToNode.cs`
the V2 port (seams doc'd on the ctor).
- `src/AcDream.Core/Physics/Motion/MoveToMath.cs` — heading/cylinder math
+ `OriginToWorld` (relocated from the deleted RemoteMoveToDriver).
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs``GetCommand` /
`FromWire` / `FromWireTurnTo` / `GetDesiredHeading` / `TowardsAndAway`.
- `docs/research/2026-07-03-r4-moveto/` — decomp + ACE cross-ref +
port plan + `V0-pins.md` + `ghidra-confirmations.md`.
- GameWindow: `EnsureRemoteMotionBindings` (the full per-remote wiring
reference), the remote UM dispatch (mt 69 vs mt-0 funnel), the
per-tick tracker+UseTime block.

View file

@ -0,0 +1,330 @@
# R4-V0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r4-moveto-decomp.md` (line-anchored); the port plan
with the full ambiguity table is `r4-port-plan.md` §0. This note records the
PINNED resolutions the V1V6 ports code against. Every pin below was produced by
an independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(line numbers cited are that file's), cross-read against ACE source
(`references/ACE/Source/ACE.Server/`, MoveToManager under `Physics/Managers/`),
and P1/P3 additionally survived an adversarial refutation pass (P3 by direct
byte-level disassembly of the PDB-matched retail binary — evidence one tier
stronger than a Ghidra decompile). **No pin was refuted; none blocks on cdb or
Ghidra.** Ghidra MCP was down for the whole pass; the deferred decompile checks
are listed per-pin and in the tail section — all confirmatory, none load-bearing.
## Pinned
- **P1 — ACE's companion mt-0 echo vs retail's unpack-head cancel: PINNED — no
adaptation needed; port the missing TAIL of retail's own gate.**
`CPhysics::SetObjectMovement` (0x00509690) drops any movement event whose wire
autonomous byte is set when the addressed object `IsThePlayer`, BEFORE
`unpack_movement` is ever called — so retail's unconditional head-interrupt
never sees an autonomous echo. Plan option (c) (suppress-when-command-matches)
is DISCARDED. Confidence: **high** (adversarially verified; refutation failed
on every angle).
Port shape: in the local-player 0xF74C path, after the S1 `MotionSequenceGate`
passes, drop the event entirely — no interrupt, no state application — when
`update.IsAutonomous && guid == local player guid`. Verbatim (raw 271370271431):
`ebx = weenie_obj->vtable->IsThePlayer()` @005096a8; two wraparound sequence
gates; then @0050972e `if ((arg7 == 0 || ebx == 0)) {
arg2->last_move_was_autonomous = arg7; CPhysicsObj::unpack_movement(arg2,
&arg3, arg4); if (ebx != 0) return 1; }` else fall to `return 0`. Note
`last_move_was_autonomous` is stored ONLY on the unpack path — do not store it
for dropped events. The `||`-vs-`&&` BN-inversion worry is refuted
behaviorally: with `&&` the local player would never unpack ANY movement event,
yet server MoveTos demonstrably drive retail clients against ACE. The 4-arg
overload (0x00509790, raw 271458271485) pins arg7 = the wire byte at offset +4
after two u16 sequences with align-to-4 — byte-identical to ACE's writer
(MovementData.cs:189-200: `GetNextSequence(ObjectMovement)`; autonomous ?
`GetCurrentSequence` : `GetNextSequence(ObjectServerControl)`;
`Convert.ToByte(motion.IsAutonomous)`; `writer.Align()`).
With the gate in place, the verbatim `unpack_movement` head-cancel ports
cleanly: `CPhysicsObj::interrupt_current_movement` + `unstick_from_object` on
EVERY UM type 0/6/7/8/9 before the type switch (0x00524440, raw 300566300573;
jump table @300707300719 — types 15 fall to return 0, head already ran).
`interrupt_current_movement` = `if (movement_manager)
MovementManager::CancelMoveTo(0x36)` (raw 278189278200), a no-op when
`moveto_manager == 0` (raw 300277300288 @0x005241b0). mt 6/7/8/9 keep the
retail unconditional cancel as planned (PerformMovement re-cancels anyway, §3a).
Why the echo was never a retail problem: the "companion mt-0 echo" is NOT
unconditional ACE behavior — it is ACE reflecting the client's OWN outbound
MoveToState back to the sender. `BroadcastMovement` has exactly ONE caller
(GameActionMoveToState.cs:36); `MovementData(Creature, MoveToState)` hardcodes
`IsAutonomous = true` (MovementData.cs:162) and Player_Networking.cs:365 sends
it to self (`EnqueueBroadcast(true, movementEvent)`; ACE's own comment
"shouldn't need to go to originating player?"). The 2026-05-14 trace behind
GameWindow.cs:4534-4546 was self-inflicted: the pre-#75 auto-walk synthesized
Forward+Run input that leaked outbound as a MoveToState
(PlayerMovementController.cs:546-557 preserves the finding); post-#75
(:298-308) no MoveToState is built during auto-walk, so no echo exists at
moveto start — **the GameWindow.cs:4534-4546 causal story ("ACE follows every
mt=0x06 with an mt=0x00") is stale**. ACE's mt-6/7/8/9 senders are
`IsAutonomous = false` (Motion.cs:18 default, copied at MovementData.cs:44;
only two `IsAutonomous = true` sites in ACE.Server: MovementData.cs:162 and
the Player.cs:944-951 jump hack), so they pass the gate and the auto-walk
trigger survives. The gate also absorbs ACE's other self-addressed autonomous
mt-0 class (the charged-jump hack) — and it is precisely why retail WASD
against ACE (every keypress echoed back IsAutonomous=1) doesn't stutter:
standing behavioral corroboration.
**Register adjudication (the exact wording V5/V6 execute):** NO new AD row.
The informal "do NOT cancel local moveto on a non-MoveTo UM" adaptation at
GameWindow.cs:4534-4546 has no register row (verified — a row-less deviation)
and is RETIRED by the ported retail mechanism; delete the comment block with
the code in V5. ONE CONTINGENCY: the gate severs acdream's only live runSkill
sync feed — GameWindow.cs:4364-4372 wires
`ApplyServerRunRate(update.MotionState.ForwardSpeed)` and its ONLY live source
is the autonomous mt-0 echo. V5 must re-anchor run-rate sync to retail's own
mechanism (mt-6/7 `my_run_rate` wire read = plan item M13, plus
PlayerDescription). If V5 instead taps the echo's ForwardSpeed BEFORE the drop,
that tap IS an ACE-compat adaptation requiring this row in the same commit:
> **AD-new | Local-player autonomous-echo ForwardSpeed tap** — acdream reads
> `InterpretedMotionState.ForwardSpeed` from the self-addressed autonomous
> mt-0 UM (ACE's MoveToState reflection, MovementData.cs:162
> IsAutonomous=true hardcoded) before the ported retail
> SetObjectMovement autonomous/IsThePlayer drop (0x00509690 @0050972e)
> discards it, feeding `PlayerMovementController.ApplyServerRunRate`. Retail
> discards the event entirely. | Risk: local/observer run-speed desync if the
> echo changes; masks the missing my_run_rate wire feed. | Retire when M13's
> mt-6/7 my_run_rate + PlayerDescription feed lands.
Adjacent seam (R5 note): retail's 0xF74C dispatch calls
`cmdinterp->vtable->LoseControlToServer()` whenever SetObjectMovement returns
nonzero — i.e. whenever a NON-autonomous movement event is applied to the
local player (raw 357214357235: instance-seq gate then `if
(CPhysics::SetObjectMovement(...) != 0) this->cmdinterp->vtable->
LoseControlToServer();`). This is the retail autonomy-handoff hook that
`BeginServerAutoWalk` currently approximates — R5/MovementManager scope.
Ghidra deferred (non-blocking): decompile `0x00509690` (re-verify the
`(arg7 == 0 || ebx == 0)` condition + which sequence stamps are written on the
reject paths — the function has BN self-subtraction flag-test garbles at
@005096db/@00509712, but the decisive gate is a plain integer test with the
adjacent `last_move_was_autonomous` store); decompile the 0xF74C dispatch case
at ~`0x00559850` (confirm LoseControlToServer fires iff SetObjectMovement != 0).
- **P2 — `get_desired_heading` (0x0052aad0) 180/0 constants: PINNED to ACE's
shape.** `RunForward|WalkForward → movingAway ? 180 : 0`; `WalkBackwards →
movingAway ? 0 : 180`; default 0. Confidence: **high**. The BN raw
(308016308033) confirms only the command GROUPING — {RunForward 0x44000007,
WalkForward 0x45000005} vs {WalkBackwards 0x45000006} vs default — while both
constant branches render identically (`result = arg3`) and the default renders
as x87 flag-synthesis nonsense (`return (arg2 - 0x45000006)`), the known
setcc-garble class. Constants + per-branch polarity are 100% ACE-sourced
(MovementParameters.cs:186-198) but doubly corroborated: (a) caller math (raw
307227307233, HandleMoveToPosition @0x00529e25) adds the return to
`Position::heading(to target)` then wraps [0,360) — a degree OFFSET; (b)
physics: §5c pure-away picks WalkForward+movingAway=1 ("heading flips via
get_desired_heading +180", r4-moveto-decomp.md:619-620) → away-walk faces
away = +180; §5d towards_and_away picks WalkBackwards+movingAway=1 inside the
min band ("backs up with WalkBackwards (no turn)", :656) → backstep faces the
target = 0. Ghidra deferred (non-blocking): decompile `0x0052aad0`; mark the
pseudocode doc UNVERIFIED-BY-GHIDRA until then.
- **P3 — `heading_diff` (0x00528fb0) third-arg mirror: PINNED as MIRROR
PRESENT — port ACE's body verbatim.** `result = h1 h2; if |result| < ε
result = 0; if result < −ε result += 360; if (result > ε && motion !=
TurnRight) result = 360 result;` with ε = 0.000199999995f and TurnRight =
0x6500000D. r4-moveto-decomp §5g's "arg3 UNUSED in the body" is a BN x87-setcc
artifact (the W6b class); r4-ace-moveto §1's suspicion was correct.
Confidence: **high** — sealed by direct disassembly of the PDB-matched binary
(`C:\Turbine\Asheron's Call\acclient.exe`, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32 = MATCH), which supersedes the deferred
Ghidra check entirely. The tail BN garbled into a fake "return flag word"
(raw 306344306346) disassembles as exactly the mirror guard:
```
00528fec fcom dword [0x007c8a00] ; result vs ε (0.00019999...)
00528ff2 fnstsw ax
00528ff4 test ah, 0x41 ; C0|C3 → result <= ε
00528ff7 jnz 0x00529009 ; skip mirror unless result > ε STRICT
00528ff9 cmp dword [esp+0xC], 0x6500000D ; arg3 vs TurnRight — arg3 IS read
00529001 je 0x00529009 ; TurnRight → skip mirror
00529003 fsubr dword [0x0079bc60] ; ST0 = 360.0f result
00529009 ret ; float return in ST0
```
The 29 bytes BN collapsed (0x00528fec→0x00529008) fit the mirror encoding to
the byte; `test ah,0x41 / jnz` encodes strict `>` (skip on less OR equal), so
ACE's `>` comparison (Managers/MoveToManager.cs:825-826) is the correct port.
Call sites: `BeginTurnToHeading` @0x00529be0 passes CONSTANT 0x6500000d
TurnRight (mirror explicitly disabled — direction pick stays the ≤180 test);
`HandleTurnToHeading` @0x0052a1bc (raw :307495) passes the LIVE
`current_command`. Two refinements over the plan's P3 row: (1) the pin's
"previous_heading update path differs" clause is FALSE — both branches of the
progress test write `previous_heading` identically (raw @0x0052a1ef /
@0x0052a206; ACE :611-621); (2) the mirror is behaviorally DEAD in this build —
its only divergent effect is `fail_progress_count` reset-vs-increment, and
that counter is write-only (6 refs in the whole raw, all writes: =0
@0x005292b1/@0x00529f3e/@0x0052a13a/@0x0052a1e5, +=1 @0x0052a014/@0x0052a224;
zero reads) — port it for verbatim fidelity + conformance tests, not because
retail-visible behavior hangs on it. No deferred check remains (Ghidra + cdb
fallback both MOOT).
- **P4 — TargetManager scope: DECISION CONFIRMED — R4 ships the minimal
App-side `TargetTracker` adapter, not a TargetManager port.** Confidence:
**high** (as a recorded decision; the retail feed constants and delivery
contract are pinned from the extraction). On `set_target(0, tlid, 0.5, 0)`
(verbatim call sites: r4-moveto-decomp.md:359 MoveToObject §3b, :423
TurnToObject §3d; constants table :1168 — radius 0.5f, initial quantum 0)
GameWindow's entity table delivers one immediate
`TargetInfo{ObjectId, Ok, target_position = entity pos, interpolated_position
= same}`, then re-delivers per tick when the target has moved > 0.5 units
since last delivery; `clear_target` unsubscribes; target despawn/teleport
delivers `status = ExitWorld/Teleported` so `CancelMoveTo 0x37/0x38` falls out
of the verbatim manager (§6d @307802307867: `!= Ok` on first callback →
0x38 NoObject :857-858; on retarget → 0x37 ObjectGone :866-867; retarget sets
`sought_position = interpolated`, `current_target_position = target`
:869-870 + FLT_MAX progress reseeds :871-874). `set_target_quantum` is
accepted and recorded but does NOT throttle (over-delivery is
convergence-safe). TargetStatus values from ACE (r4-ace-moveto.md:654-656):
Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5,
TimedOut=6; ACE's HandleTargetting cadence is 0.5 s with a 10 s
Undefined→TimedOut timeout. Wire inputs available today: mt-6 TargetGuid
(UpdateMotion.cs:310-315, parsed-but-unused = gap M8 — the subscription key);
wire Origin (:319-326) doubles as the first-delivery fallback when the guid
is unresolvable, mirroring retail's own case-6 degrade to
MoveToPosition(wire origin) (r4-moveto-decomp.md:290-292). NOT on the wire:
target radius/height for `cylinder_distance` (from the local entity table)
and ongoing target updates (the adapter's per-tick tracking; ACE's ~1 Hz
MoveTo re-emit is the backstop). **Register row (AP class) lands in V4,
replacing AP-8's "no target re-tracking" clause; full TargetManager port is
R5 scope.** Fallback if the adapter proves too loose in V4: grep
`TargetManager::` in the raw (~200 lines) and port verbatim.
- **P5 — Position::heading convention: PINNED — compass degrees, 0 = North
(+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion faces heading
0.** Formula: `heading(from, to) = (450 atan2Deg(dy, dx)) % 360`. Wire
`DesiredHeading` is in THIS convention (retail @0052a71d feeds it straight
into `Frame::set_heading` on sought_position, §3e). Golden cardinals:
N(0,+1)→0, E(+1,0)→90, S(0,1)→180, W(1,0)→270. Confidence: **high**.
Evidence: `Position::heading` 0x005a9520 (raw 438288438290): `450.0
fpatan(...) × 57.295779513082323`, fmod tail; ACE Position.cs:200-209 pins the
atan2 arg order (`Atan2(dir.Y, dir.X)`); `Frame::get_heading` 0x00535760 (raw
319781) same formula on the forward basis (ACE AFrame.cs:147-152);
`Frame::set_heading` 0x00535e40 (raw 320055320066) round-trips — h=0 →
forward (sin 0, cos 0) = +Y. An in-tree twin of the formula already exists at
SceneryHelpers.cs:75.
**⚠ CORRECTION to the plan's own pin method — the packer-reuse trap.** The
plan says "the outbound AP packer already encodes body yaw→AC heading; REUSE
that exact conversion". Reuse it at the **QUATERNION level ONLY**
(`YawToAcQuaternion`/`AcQuaternionToYaw` are wire-correct end-to-end — its
two internal errors cancel). Its internal SCALAR intermediate
(GameWindow.cs:8083-8103: `headingDeg = 180f yawDeg`) is **holtburger's
convention = retail + 90°**, and its comment "AC heading: 0=West, 90=North,
180=East, 270=South" (from holtburger math.rs:56, whose identity-quat note
:146-148 says "Heading 90 (North)") is WRONG as a statement of the retail wire
convention. Literal scalar reuse would face `use_final_heading` and every
`get_desired_heading` offset 90° wrong. The correct scalar bridge from acdream
yaw (yaw=0 faces +X per PlayerMovementController.cs:1022-1025) is
`heading = (90 yawDeg) mod 360`. Quaternion-level proof:
YawToAcQuaternion(yaw=0) → (z=0.7071, w=+0.7071), from which ACE's
AFrame.get_heading extracts 90 = East — correct for a +X-facing body.
- **P6 — TurnToObject (mt 8) wire layout: PINNED byte-for-byte from ACE's
writer, matching the decomp read order exactly.** After the common UM header
(u8 movementType, u8 motionFlags, u16 currentStyle — MovementData.cs:202-205):
`[u32 object guid][f32 standalone heading][u32 params bitfield][f32 speed]
[f32 params desired_heading]`. mt 9 (TurnToHeading) = the 3-dword
TurnToParameters only. Confidence: **high**. The standalone second field is
ACE `TurnToObject.DesiredHeading` = retail's `wire_heading`, consumed ONLY in
the unresolvable-object fallback: substituted into `params.desired_heading`,
then degrade to TurnToHeading (decomp §2f case 8, r4-moveto-decomp.md:293-298:
`object_id = read_dword(); wire_heading = read_dword(); UnPackNet(...); if
(GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; goto
label_524725; }`; ACE TurnToObject.cs:25-31 + field comment :10-11 "used
instead of the DesiredHeading in the TurnToParameters"). UnPackNet 0xc-byte
form per §2g (:311): bitfield, speed, desired_heading. Gap M7 confirmed:
UpdateMotion.cs:252 `else if (movementType is 6 or 7)` — mt 8/9 payloads
silently discarded today. **V3 fixture caveat:** ACE populates BOTH heading
fields from the same source (TurnToObject.cs:17 and TurnToParameters.cs:18
both copy `motion.DesiredHeading`), so ACE-golden fixtures always have
field2 == field5 — distinguish the two fields by OFFSET (hand-vary one in
synthetic fixtures), never by value.
- **P7 — remote Contact for the UseTime gate: PINNED — Contact IS live for
grounded remotes; no state-writer fix needed.** Confidence: **high**. Remote
bodies are BORN with `Contact | OnWalkable | Active` (GameWindow.cs:580-591)
and BOTH remote per-tick paths re-assert `TransientState |= Contact |
OnWalkable | Active` unconditionally every tick while `!rm.Airborne`
(:9366-9370 player-remote pipeline; :9570-9574 NPC/legacy — the path
RemoteMoveToDriver runs on today). The bits are cleared ONLY for the airborne
arc (:4833-4841, `Velocity.Z > 0.5f` launch) and restored on landing by two
independent paths (:5161-5167 UP grounded snap; :9808-9818 resolver landing —
which already calls `rm.Motion.HitGround()`, the exact retail re-arm seam
`MoveToManager::HitGround` §6e binds to: `if (movement_type == Invalid)
return; BeginNextNode(this);`, r4-moveto-decomp.md:884-889). Retail UseTime
gates on `transient_state & 1` (Contact only, @307781) — a strict SUBSET of
the funnel's `Contact && OnWalkable` that demonstrably works for remote
Falling dispatch today (MotionInterpreter.cs:2089-2092). A chasing grounded
NPC passes the gate every tick; an airborne remote stalls the moveto until
landing — retail behavior by design. The pin-method's live
`ACDREAM_DUMP_MOTION` smoke was NOT run (read-only pass; the write is
unconditional so the smoke is confirmatory only) — **folded into V4's
acceptance run** (already listed there: "Live smoke: NPC chase +
ACDREAM_DUMP_MOTION").
## Pending
None. No pin was refuted or left uncorrectable.
## Deferred non-blocking checks (Ghidra MCP down during V0)
All three are confirmatory — the textual adjudications above are decisive.
| Address | Function | What to confirm |
|---|---|---|
| `0x00509690` | `CPhysics::SetObjectMovement` (7-arg) | The `(arg7 == 0 \|\| ebx == 0)` gate condition; which sequence stamps are written on the reject paths (BN garbles @005096db/@00509712 are self-subtraction flag-test artifacts) |
| ~`0x00559850` | 0xF74C dispatch case | `LoseControlToServer()` fires iff `SetObjectMovement != 0` |
| `0x0052aad0` | `MovementParameters::get_desired_heading` | The 180/0 constants + per-branch polarity (currently ACE-sourced; grouping BN-verified) |
P3's deferred check (`0x00528fb0`) is MOOT — resolved by direct disassembly of
the PDB-matched binary (see the pin).
## Adjacent findings (load-bearing for V-commits, discovered while pinning)
- **runSkill-sync severance (V5 trap, from P1):** GameWindow.cs:4364-4372's
`ApplyServerRunRate(ForwardSpeed)` is fed EXCLUSIVELY by the autonomous mt-0
echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7
`my_run_rate` wire read → `Motion.MyRunRate`) + PlayerDescription — or ships
the contingent AD row quoted in P1.
**V5 EXECUTED (2026-07-03): re-anchor taken, NO AD row needed.** Both
retail-mechanism feeds already existed: PlayerDescription run/jump skills
flow via `SetCharacterSkills` (K-fix7, wired since before V5 — the
"we don't parse PD yet" comment in the controller ctor was stale) into
`PlayerWeenie.InqRunRate`, which `apply_run_to_command`/`get_state_velocity`
PREFER over `MyRunRate`; and V5's shared `RouteServerMoveTo` performs the
M13 `Motion.MyRunRate = MoveToRunRate` write for the local player on
mt-6/7. `ApplyServerRunRate` was deleted outright — its
`InterpretedState.ForwardSpeed` overwrite was a pre-R3 mechanism that
fought the ported machinery (any DoMotion edge recomputes ForwardSpeed
through adjust_motion/apply_run_to_command).
- **LoseControlToServer seam (R5):** retail hands autonomy to the server at the
0xF74C dispatch whenever a non-autonomous movement event is applied to the
local player (raw 357214357235). Note for R5/MovementManager — the retail
hook `BeginServerAutoWalk` approximates today.
- **GameWindow.cs:4534-4546 comment is stale** (from P1): the "ACE follows every
mt=0x06 with an mt=0x00" causal story described the pre-#75 build's
self-inflicted echo; post-#75 no echo exists at moveto start. The comment
block dies with the code in V5 — do not port against it.
- **RemoteMoveToDriver.cs:53-57 stale claim FIXED in this commit** (plan §0
mandate): the class doc claimed "ACE swaps the chase/flee arrival predicates"
— refuted by r4-ace-moveto §1 and by the file's own :186-199 comment; retail
and ACE agree (chase arrives `dist <= distance_to_object`, flee arrives
`dist >= min_distance`).
## V0 cdb capture (pending, non-blocking)
No pin above depends on it — P1/P3 were adversarially sealed textually (P3 down
to the instruction bytes). One live retail session feeds V2/V4 goldens: bp
`MoveToManager::PerformMovement` / `BeginMoveForward` / `BeginTurnToHeading` /
`HandleMoveToPosition` / `HandleUpdateTarget` /
`MovementParameters::get_command` (args+ret) while the user runs the plan §0
protocol (door-use at range, use-while-facing-away, monster chase, outrun). A
`heading_diff` arg3+ST0 log during a TurnLeft-direction TurnToHeading would
spot-check P3's mirror live — nice-to-have only.

View file

@ -0,0 +1,58 @@
# R4-V0 — Ghidra ground-truth decompiles (patchmem.gpr, fetched live 2026-07-03)
Authoritative resolutions for the two pins the BN pseudo-C garbled. Fetched
via the Ghidra MCP while the V0 pin workflow ran; these OVERRIDE any textual
adjudication that disagrees.
## P3 — `heading_diff` 0x00528fb0: the TurnLeft mirror EXISTS
```c
float __cdecl heading_diff(float param_1, float param_2, ulong param_3)
{
float fVar1;
fVar1 = param_1 - param_2;
if (ABS(param_1 - param_2) < F_EPSILON) {
fVar1 = 0.0f;
}
if (fVar1 < -F_EPSILON) {
fVar1 = fVar1 + 360.0f; // ___real_43b40000
}
if ((F_EPSILON < fVar1) && (param_3 != 0x6500000d)) {
fVar1 = 360.0f - fVar1; // the mirror — NOT-TurnRight only
}
return fVar1;
}
```
Adjudication of the research contradiction:
- `r4-moveto-decomp.md` §5g ("turn-command arg UNUSED") is **WRONG** — the
arg gates the mirror.
- `r4-ace-moveto.md`'s suspicion (an x87-garbled `360diff` TurnLeft branch)
is **CONFIRMED**: the mirror applies whenever the turn command is not
TurnRight (0x6500000d), i.e. the TurnLeft direction measures the
complementary angle. Port verbatim with the epsilon literal
0.000199999995f (F_EPSILON).
## P2 — `MovementParameters::get_desired_heading` 0x0052aad0: ACE-shaped constants CONFIRMED
```c
float __thiscall MovementParameters::get_desired_heading(
MovementParameters* this, ulong command, int movingAway)
{
if ((command == 0x44000007) || (command == 0x45000005)) { // Run/WalkForward
if (movingAway == 0)
return 0.0f;
}
else {
if (command != 0x45000006) // not WalkBackward
return 0.0f;
if (movingAway != 0)
return 0.0f;
}
return 180.0f; // ___real_43340000
}
```
Truth table: forward+towards → 0°; forward+away → 180°;
backward+towards → 180°; backward+away → 0°; any other command → 0°.

View file

@ -0,0 +1,857 @@
# R4 ACE cross-reference — MoveToManager / MovementParameters
**Purpose:** ACE-side oracle for the R4 MoveToManager port. Per-method transcription of
`references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` (874 lines) +
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, call-site map,
ACE-ism flags (each spot-checked against the named retail decomp where noted), and the
acdream R4 blast-radius inventory.
**Note on paths:** the ACE reference lives in the MAIN repo tree
(`C:/Users/erikn/source/repos/acdream/references/ACE/...`), NOT in the worktree — the worktree's
`references/` only contains WorldBuilder. ACE's file is `Physics/Managers/MoveToManager.cs`
(namespace still says `Physics.Animation` — ACE moved the file without renaming the namespace).
**Retail anchor table** (named pseudo-C, `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
| Function | Address | pseudo-C line |
|---|---|---|
| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 |
| `heading_greater` (free fn) | 0x00528f60 | 306281 |
| `heading_diff` (free fn) | 0x00528fb0 | 306327 |
| `MoveToManager::_DoMotion` | 0x00529010 | 306351 |
| `MoveToManager::_StopMotion` | 0x00529080 | 306368 |
| `MoveToManager::CheckProgressMade` | 0x005290f0 | 306385 |
| `MoveToManager::GetCurrentDistance` | 0x005291b0 | 306435 |
| `MoveToManager::is_moving_to` | 0x00529220 | 306464 |
| `MoveToManager::InitializeLocalVariables` | 0x00529250 | 306490 |
| `MoveToManager::RemovePendingActionsHead` | 0x00529380 | 306538 |
| `MoveToManager::MoveToManager` (ctor) | 0x005293b0 | 306554 |
| `MoveToManager::CleanUp` | 0x005295c0 | 306710 |
| `MoveToManager::CleanUpAndCallWeenie` | 0x00529650 | 306740 |
| `MoveToManager::MoveToObject` | 0x00529680 | 306756 |
| `MoveToManager::TurnToObject` | 0x005297d0 | 306820 |
| `MoveToManager::CancelMoveTo` | 0x00529930 | 306886 |
| `MoveToManager::BeginMoveForward` | 0x00529a00 | 306957 |
| `MoveToManager::BeginTurnToHeading` | 0x00529b90 | 307046 |
| `MoveToManager::BeginNextNode` | 0x00529cb0 | 307123 |
| `MoveToManager::HitGround` | 0x00529d70 | 307175 |
| `MoveToManager::HandleMoveToPosition` | 0x00529d80 | 307187 |
| `MoveToManager::HandleTurnToHeading` | 0x0052a0c0 | 307442 |
| `MoveToManager::MoveToPosition` | 0x0052a240 | 307521 |
| `MoveToManager::MoveToObject_Internal` | 0x0052a400 | 307597 |
| `MoveToManager::TurnToObject_Internal` | 0x0052a550 | 307667 |
| `MoveToManager::TurnToHeading` | 0x0052a630 | 307706 |
| `MoveToManager::UseTime` | 0x0052a780 | 307776 |
| `MoveToManager::HandleUpdateTarget` | 0x0052a7d0 | 307802 |
| `MoveToManager::PerformMovement` | 0x0052a900 | 307871 |
| `MovementParameters::towards_and_away` | 0x0052a9a0 | 307917 |
| `MovementParameters::get_command` | 0x0052aa00 | 307946 |
| `MovementParameters::get_desired_heading` | 0x0052aad0 | 308016 |
| `MovementParameters::Pack / UnPack / UnPackNet` | 0x0052ab20 / 0x0052abc0 / 0x0052ac50 | 308037 / 308078 / 308118 |
Retail struct layout (verbatim PDB, `acclient.h:31473-31497`): `movement_type, sought_position,
current_target_position, starting_position, movement_params, previous_heading, previous_distance,
previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count,
sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command,
aux_command, moving_away, initialized, pending_actions (DLList<MovementNode>), physics_obj, weenie_obj`.
`MovementNode : DLListData { MovementTypes::Type type; float heading; }` (`acclient.h:57702`).
---
## 1. ACE MoveToManager — state
`MoveToManager.cs:13-35`:
```
MovementType MovementType // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9
Position SoughtPosition // MoveToObject: interpolated target pos; also carries heading for TurnTo*
Position CurrentTargetPosition // raw target position (distance measured against THIS)
Position StartingPosition // for FailDistance check
MovementParameters MovementParams
float PreviousHeading // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful)
float PreviousDistance; double PreviousDistanceTime
float OriginalDistance; double OriginalDistanceTime
int FailProgressCount // incremented, NEVER read (see §5 negative results — retail-faithful)
uint SoughtObjectID, TopLevelObjectID
float SoughtObjectRadius, SoughtObjectHeight
uint CurrentCommand // active forward/turn motion (full 32-bit command)
uint AuxCommand // in-motion heading-correction turn command
bool MovingAway, Initialized
List<MovementNode> PendingActions // MovementNode { MovementType Type; float Heading }
PhysicsObj PhysicsObj; WeenieObject WeenieObj
bool AlwaysTurn // ACE-ONLY (no retail counterpart) — see flag A7
```
MotionCommand constants used: `WalkForward = 0x45000005`, `WalkBackwards = 0x45000006`,
`TurnRight = 0x6500000D`, `TurnLeft = 0x6500000E`, `RunForward = 0x44000007`.
`PhysicsGlobals.EPSILON = 0.0002f` (retail literal `0.000199999995f` everywhere in this family).
### Init / InitializeLocalVars (`:57-89`)
```
Init(): MovementParams = new MovementParameters(); PendingActions = new List<MovementNode>();
InitializeLocalVars():
MovementType = Invalid
MovementParams.DistanceToObject = 0 // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object
MovementParams.ContextID = 0
PreviousDistanceTime = OriginalDistanceTime = now
PreviousHeading = 0
FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false
SoughtPosition = new Position(); CurrentTargetPosition = new Position()
SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0
// ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both
```
### PerformMovement(MovementStruct mvs) (`:91-112`) — retail 0x0052a900 ✓ matches
```
CancelMoveTo(WeenieError.ActionCancelled) // 0x36 — retail same constant
PhysicsObj.unstick_from_object() // ⚠ no null check; retail also derefs unconditionally here
switch (mvs.Type):
MoveToObject → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params)
MoveToPosition → MoveToPosition(mvs.Position, mvs.Params)
TurnToObject → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params)
TurnToHeading → TurnToHeading(mvs.Params)
return WeenieError.None // retail returns 0 unconditionally too
```
### MoveToObject(objectID, topLevelID, radius, height, params) (`:114-139`) — retail 0x00529680 ✓
```
if (PhysicsObj == null) return // retail: null branch still runs StopCompletely-if-nonnull tail (no-op)
PhysicsObj.StopCompletely(false)
StartingPosition = copy(PhysicsObj.Position)
SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height
MovementType = MoveToObject; TopLevelObjectID = topLevelID
MovementParams = copy(movementParams) // retail: full field-by-field copy incl flags; Sticky NOT unset here
Initialized = false
if (PhysicsObj.ID != topLevelID):
PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0) // contextID=0, radius=0.5, quantum=0 — retail identical constants
return
CleanUp(); PhysicsObj.StopCompletely(false) // self-target degenerate path
```
No nodes are queued here — the chain continues via TargetManager voyeur callback →
`HandleUpdateTarget``MoveToObject_Internal`.
### MoveToObject_Internal(targetPosition, interpolatedPosition) (`:141-182`) — retail 0x0052a400 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return }
SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition)
iHeading = PhysicsObj.Position.heading(interpolatedPosition) // note: heading measured to INTERPOLATED pos
heading = iHeading - PhysicsObj.get_heading()
dist = GetCurrentDistance()
if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360
MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway) // on the STORED member — retail 0052a4d6 same
if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading): // member read is CORRECT here (params stored by MoveToObject already)
dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360
AddTurnToHeadingNode(dHeading)
Initialized = true
BeginNextNode()
```
### MoveToPosition(position, params) (`:184-228`) — retail 0x0052a240 — ⚠ flag A1
```
if (PhysicsObj == null) return
PhysicsObj.StopCompletely(false)
CurrentTargetPosition = copy(position); SoughtObjectRadius = 0
distance = GetCurrentDistance()
headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above
movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway) // on the ARGUMENT — retail 0052a304 same
if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading) // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads
AddTurnToHeadingNode(movementParams.DesiredHeading) // the ARGUMENT's flag byte (arg3->__inner0 & 0x40)
SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position)
MovementType = MoveToPosition
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail: full copy then __inner0 &= 0xffffff7f ✓
BeginNextNode()
```
### TurnToObject(objectID, topLevelID, params) (`:230-259`) — retail 0x005297d0 ✓
```
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) // retail gates on flag byte2 & 1 (0x10000) ✓
MovementType = TurnToObject; SoughtObjectID = objectID
CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓
TopLevelObjectID = topLevelID
MovementParams = copy(movementParams) // retail: full copy; Sticky NOT unset (unlike TurnToHeading)
if (PhysicsObj.ID != topLevelID):
Initialized = false
PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0)
return
CleanUp(); PhysicsObj.StopCompletely(false)
```
Note the desired-heading value written into `CurrentTargetPosition.Frame` is CLOBBERED by
`TurnToObject_Internal` before it is ever read (Internal overwrites CurrentTargetPosition from the
wire target and reads `SoughtPosition.Frame`'s heading, which InitializeLocalVars reset to 0).
Verified this is a *retail* quirk, not an ACE bug — the retail decomp does exactly the same
(0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim.
### TurnToObject_Internal(targetPosition) (`:261-282`) — retail 0x0052a550 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
CurrentTargetPosition = copy(targetPosition)
targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition)
soughtHeading = SoughtPosition.Frame.get_heading()
heading = (targetHeading + soughtHeading) % 360 // retail: fmod(…, 360) via CIfmod
SoughtPosition.Frame.set_heading(heading)
PendingActions.Add(TurnToHeading node, heading) // retail: node type 9
Initialized = true
BeginNextNode()
```
### TurnToHeading(params) (`:284-304`) — retail 0x0052a630 — ⚠ flag A4
```
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false)
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail __inner0 &= 0xffffff7f ✓
SoughtPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052a71d ✓
MovementType = TurnToHeading
PendingActions.Add(TurnToHeading node, DesiredHeading)
// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert.
```
### BeginNextNode (`:316-349`) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback)
```
if (PendingActions non-empty):
head.Type == MoveToPosition → BeginMoveForward()
head.Type == TurnToHeading → BeginTurnToHeading()
(other types: fall through / return — retail explicitly returns for any other type)
else:
if (MovementParams.Sticky): // retail: signed test on flags byte0 (bit 0x80)
capture (radius, height, topLevelID) BEFORE CleanUp
CleanUpAndCallWeenie(WeenieError.None) // retail: CleanUp + StopCompletely inline — NO weenie call
if (PhysicsObj != null)
PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height)
else:
CleanUpAndCallWeenie(WeenieError.None)
```
### BeginMoveForward (`:351-399`) — retail 0x00529a00 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
dist = GetCurrentDistance()
heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize
MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway) // stored member ✓
if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { HoldKeyToApply = holdKey, // the get_command RESULT — retail 00529af4 ✓
CancelMoveTo = false, // retail flags &= 0xffff7fff ✓
Speed = MovementParams.Speed }
result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motion; MovingAway = moveAway
MovementParams.HoldKeyToApply = holdKey // write-back onto stored params — retail 00529b45 ✓
PreviousDistance = OriginalDistance = dist
PreviousDistanceTime = OriginalDistanceTime = now
```
### HandleMoveToPosition (`:404-504`) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
curPos = copy(Position)
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
HoldKeyToApply = MovementParams.HoldKeyToApply } // retail 00529dc7-00529dea ✓
if (!PhysicsObj.IsAnimating): // retail: motions_pending() == 0
heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition)
if (heading >= 360) heading -= 360
diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON)
if (diff > 20 && diff < 340): // retail constants 20 / (360-20)
motionID = (diff >= 180) ? TurnLeft : TurnRight
if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID }
else:
if (AuxCommand != 0) PhysicsObj.set_heading(heading, true) // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate")
stop_aux_command(mp) // retail only stops the aux command, no snap
else:
stop_aux_command(mp) // retail: aux stop under motions_pending ✓
dist = GetCurrentDistance()
if (!CheckProgressMade(dist)):
if (!IsInterpolating() && !IsAnimating) FailProgressCount++ // retail identical gate ✓
else:
inRange = false // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate")
if (!MovementParams.UseSpheres): // including the extra PreviousDistance/Time refresh
if (dist < 1.0 && PreviousDistance < dist) inRange = true
PreviousDistance = dist; PreviousDistanceTime = now
FailProgressCount = 0
if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange):
PendingActions.RemoveAt(0)
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
stop_aux_command(mp) // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓
BeginNextNode()
else:
if (StartingPosition.Distance(Position) > MovementParams.FailDistance)
CancelMoveTo(YouChargedTooFar) // 0x3D — retail same constant (00529f79)
if (TopLevelObjectID != 0 && MovementType != Invalid):
v = |get_velocity()|
if (v > 0.1): // retail double literal 0.1 ✓
time = dist / v
if (|time - get_target_quantum()| > 1.0) set_target_quantum(time) // retail 1.0 ✓
```
Arrival predicate cross-check: flee branch is explicit in the retail decomp
(`dist >= min_distance` → arrival, 00529f44-00529f51). The chase branch's literal BN rendering
*appears* inverted (`dist <= distance_to_object` branching to the fail-distance check) — that is a
known `test ah, 0x41` branch-sense garble; the flee branch + symmetric structure + two prior
independent research passes (see `RemoteMoveToDriver.cs:186-199`) fix the correct reading as:
**chase arrives at `dist <= distance_to_object`, flee arrives at `dist >= min_distance`** — which
is exactly ACE's line 476. (The stale class-doc comment in acdream's `RemoteMoveToDriver.cs:53-57`
claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the
same file — see §6 blast radius.)
### BeginTurnToHeading (`:510-565`) — retail 0x00529b90 — ⚠ flags A7, A10
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
// ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null;
// ACE indexes PendingActions[0] later (throws if empty — callers guard)
if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only
headingDiff = heading_diff(node.Heading, get_heading(), TurnRight)
if (headingDiff <= 180):
if (headingDiff > EPSILON) motionID = TurnRight
else { RemovePendingActionsHead(); BeginNextNode(); return }
else:
if (headingDiff + EPSILON <= 360) motionID = TurnLeft
else { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
HoldKeyToApply = MovementParams.HoldKeyToApply }
result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motionID
PreviousHeading = headingDiff // stores the DIFF, not a heading — retail 00529c82 identical quirk
```
(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure —
`heading_diff(node, current, TurnRight)`, ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon —
matches ACE's reading. Constants: 180, 360, EPSILON.)
### HandleTurnToHeading (`:570-621`) — per-tick turn driver — retail 0x0052a0c0 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return }
node = PendingActions[0]; heading = get_heading()
if (heading_greater(heading, node.Heading, CurrentCommand)): // crossed the target heading
FailProgressCount = 0
PhysicsObj.set_heading(node.Heading, true) // snap exactly — retail 0052a146 ✓ (this snap IS retail)
RemovePendingActionsHead()
mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply }
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
BeginNextNode(); return
diff = heading_diff(heading, PreviousHeading, CurrentCommand)
if (diff > EPSILON && diff < 180):
FailProgressCount = 0; PreviousHeading = heading
else:
PreviousHeading = heading
if (!IsInterpolating() && !IsAnimating) FailProgressCount++
```
Note ACE omits `movementParams.Speed` in the stop-params here (retail also builds a default-speed
params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓).
### HandleUpdateTarget(TargetInfo) (`:623-666`) — retail 0x0052a7d0 ✓ (reordered but equivalent)
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (TopLevelObjectID != targetInfo.ObjectID) return
if (Initialized):
if (status == OK):
if (MovementType == MoveToObject):
SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition)
PreviousDistance = OriginalDistance = float.MaxValue
PreviousDistanceTime = OriginalDistanceTime = now
else CancelMoveTo(ObjectGone) // 0x37 — retail same (0052a80f)
else if (TopLevelObjectID == PhysicsObj.ID):
SoughtPosition = CurrentTargetPosition = copy(Position)
CleanUpAndCallWeenie(None)
else if (status == OK):
MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition)
MovementType == TurnToObject → TurnToObject_Internal(TargetPosition)
else CancelMoveTo(NoObject) // 0x38 — retail same (0052a8bd)
```
Retail evaluates `!initialized` first and nests the self-target + status checks inside it; ACE
flipped to `if (Initialized)` first. Same reachable behavior; error codes verified identical
(0x37 initialized-path, 0x38 uninitialized-path — no transposition).
### CheckProgressMade(currDistance) (`:668-688`) — retail 0x005290f0 ✓
```
deltaTime = now - PreviousDistanceTime
if (deltaTime > 1.0): // 1-second window
diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr
if (diffDist / deltaTime < 0.25) return false // 0.25 m/s minimum closing rate
PreviousDistance = curr; PreviousDistanceTime = now
dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr
if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false
return true
```
### CancelMoveTo(retval) (`:690-699`) — retail 0x00529930 ✓
```
if (MovementType == Invalid) return
PendingActions.Clear() // retail: DLList drain + delete
CleanUpAndCallWeenie(retval)
```
### CleanUp (`:701-719`) — retail 0x005295c0 ✓
```
mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false }
if (PhysicsObj != null):
if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp)
if (AuxCommand != 0) _StopMotion(AuxCommand, mp)
if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target()
InitializeLocalVars()
```
### CleanUpAndCallWeenie(status) (`:721-729`) — retail 0x00529650 — ⚠ flag A5
```
CleanUp()
if (PhysicsObj != null) PhysicsObj.StopCompletely(false)
WeenieObj.OnMoveComplete(status) // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY
// (despite the function name, the 2013 client build has no weenie
// call here — compiled out / client-side no-op).
// ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if
// constructed via the parameterless ctor (flag A11).
```
### GetCurrentDistance (`:731-741`) — retail 0x005291b0
```
if (PhysicsObj == null) return float.MaxValue
if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition)
return Position.CylinderDistance(GetRadius(), GetHeight(), Position,
SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition)
```
Distance is measured against **CurrentTargetPosition** in both variants; the sphere variant folds
in both bodies' radius/height (this is why ACE chase packets can carry `distance_to_object = 0.6`
and still stop at melee range).
### HitGround (`:743-747`) — retail 0x00529d70 ✓
```
if (MovementType != Invalid) BeginNextNode()
```
### UseTime (`:765-787`) — retail 0x0052a780 — ⚠ flag A12 (gate inversion)
```
if (PhysicsObj == null || !TransientState.Contact) return // retail: transient_state & 1 ✓
if (PendingActions.Count == 0) return
if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized): // ⚠ A12: retail gate is
head.Type == MoveToPosition → HandleMoveToPosition() // (top_level_object_id == 0
head.Type == TurnToHeading → HandleTurnToHeading() // || movement_type == Invalid
// || initialized != 0)
```
Retail (0052a7b4): proceed when `topLevel == 0 || type == Invalid || initialized` — i.e. **skip
only while a MoveToObject/TurnToObject is still waiting for its first target resolution**. ACE
negated the first two clauses. In practice ACE is masked by the `PendingActions.Count == 0` early
return (nodes only exist after `*_Internal` ran, which also sets `Initialized`), but port the
retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear
PendingActions) differs.
### _DoMotion / _StopMotion (`:789-815`) — retail 0x00529010 / 0x00529080 ✓
```
if (PhysicsObj == null) return NoPhysicsObject // 8
minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter // 0xB
minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply)
return minterp.DoInterpretedMotion(motion, movementParams) // _StopMotion → StopInterpretedMotion
```
The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again —
`MotionInterp.cs:117-129`) **is retail-faithful**: retail's 0x00529057 calls
`CMotionInterp::adjust_motion` and `CMotionInterp::DoInterpretedMotion` also adjusts internally.
Do NOT "fix" the double adjust.
### heading_diff(h1, h2, motion) (`:817-828`) — retail free fn 0x00528fb0 (x87-garbled)
```
result = h1 - h2
if (|result| < EPSILON) result = 0
if (result < -EPSILON) result += 360
if (result > EPSILON && motion != TurnRight) result = 360 - result // mirror for TurnLeft
```
The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word
int) and doesn't show the `motion != TurnRight` mirror — the R4 port should verify the mirror once
in Ghidra (`/decompile_function?address=0x00528fb0`), but ACE's shape is consistent with every call
site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff).
### heading_greater(h1, h2, motion) (`:830-858`) — retail free fn 0x00528f60 ✓
```
diff = |h1 - h2|
if (diff <= 180): result = h1 > h2
else: result = h2 > h1
if (motion != TurnRight) result = !result
return result
```
ACE's rewrite verified equivalent to the retail comparison tree (including the equality case —
equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE
comment at `:832-836`.
### is_moving_to / stop_aux_command / small helpers (`:860-872`, `:306-314`, `:749-763`)
```
is_moving_to() => MovementType != Invalid
stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 }
AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition)) // retail node type 7
AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h)) // retail node type 9
RemovePendingActionsHead(): if (Count > 0) RemoveAt(0)
SetPhysicsObject / SetWeenieObject: plain assignments
```
---
## 2. ACE MovementParameters (`Physics/Animation/MovementParameters.cs`)
Bool-per-flag class; `Flags` property round-trips through `MovementParamFlagsHelper`
(`MovementParamFlags.cs`): `CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8,
CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200,
UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000,
ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000,
DisableJumpDuringLink 0x20000`. Matches `acclient.h` bit assignments (cross-checked against
acdream `UpdateMotion.cs` wire comments).
### Defaults (ctor, `:52-85`) — retail 0x00524380 — ⚠ flags A13, A14
| Field | ACE default | Retail default |
|---|---|---|
| flags | CanWalk+CanRun+CanSidestep+CanWalkBackwards+**CanCharge**+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = **0x1EE1F** | **0x1EE0F** (CanCharge 0x10 CLEAR) — ⚠ A13 |
| DistanceToObject | 0.6f | 0.6f ✓ |
| FailDistance | float.MaxValue | FLT_MAX ✓ |
| MinDistance | 0 | 0 ✓ |
| Speed | 1.0f | 1.0f ✓ |
| WalkRunThreshold | **1.0f** (15.0f commented out) | **15.0f** — ⚠ A14 (ACE server tuning) |
| DesiredHeading | 0 | 0 ✓ |
| HoldKeyToApply | Invalid | Invalid ✓ |
| ContextID / ActionStamp | 0 | 0 ✓ |
(Retail caches the default flag word in a static `set_moveto_flags::normal_bitfield` — one-time
compute of 0x1EE0F, 005243c1-005243e1.)
### Copy constructors (`:90-150`)
- `MovementParameters(mvp, onlyBits=false)` — copies Flags always, plus non-flag fields
(`CopyNonFlag`: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed,
WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless `onlyBits`.
Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent.
- `CopySome(mvp)` — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability +
behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway,
Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE `TODO: review`
interop with the R3 MotionInterp port, not an R4 concern per se.)
- `MovementParameters(MoveToParameters)` — wire-side ctor (server outbound); flags from the packed
dword + the six floats. Matches `MovementParameters::UnPackNet` 0x0052ac50 field order.
### get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (`:152-184`) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped)
ACE:
```
if (MoveTowards || !MoveAway):
if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway)
else:
if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0
else if (MoveAway):
if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0
if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run
else holdKey = None
```
Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode):
```
if (MoveTowards):
if (MoveAway) towards_and_away(...) // both set → bidirectional band
else chase-only (dist > distance_to_object → WalkForward, away=0; else 0)
else if (!MoveAway): chase-only // neither set → still chase-only (matches ACE's OR)
else: flee-only (dist < min_distance WalkForward, away=1; else 0)
// HoldKey (0052aa90-0052aab4):
if (flags & 0x10 /*CanCharge*/) → HoldKey_Run // ⚠ unconditional run fast-path
else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None
else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None
else → HoldKey_Run
```
Movement-command selection matches ACE exactly. The hold-key selection does NOT: **retail's
CanCharge (0x10) bit short-circuits straight to HoldKey_Run** before any CanRun/CanWalk/threshold
logic; ACE dropped it entirely. (This is the already-documented #77 finding —
`memory/feedback_autowalk_cancharge_bit.md`; acdream's B.6 honors the wire CanCharge bit.) Note the
two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's
own server, but a verbatim port must take retail's version of BOTH.
### towards_and_away(dist, heading, ref command, ref movingAway) (`:200-214`) — retail 0x0052a9a0 ✓
```
if (dist > DistanceToObject) { command = WalkForward; movingAway = false }
else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true } // retail 0x45000006
else command = 0
```
(Note: `heading` param is unused in both ACE and retail.)
### get_desired_heading(motion, movingAway) (`:186-198`) — retail 0x0052aad0 ✓
```
RunForward / WalkForward → movingAway ? 180 : 0
WalkBackwards → movingAway ? 0 : 180
default → 0
```
---
## 3. Call sites into MoveToManager (ACE)
**MovementManager** (`Physics/Managers/MovementManager.cs`) — owns exactly one `MotionInterpreter`
+ one `MoveToManager`:
- `PerformMovement(mvs)` (`:124-157`): `PhysicsObj.set_active(true)` (ACE-ism, server activity
tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state),
types 6-9 → MoveToManager.PerformMovement (lazy-create), default → `WeenieError.GeneralMovementFailure`.
- `CancelMoveTo(err)` (`:27`), `HandleUpdateTarget(targetInfo)` (`:60`), `UseTime()` (`:176`,
forwards to MoveToManager only), `HitGround()` (`:66-73`, forwards to BOTH MotionInterp.HitGround
and MoveToManager.HitGround), `IsMovingTo()` (`:97`), `MakeMoveToManager()` (`:112`),
`SetWeenieObject` (`:167`).
**PhysicsObj** (`Physics/PhysicsObj.cs`) seams that drive the manager:
- `UseTime` ticks: `:1729` + `:1791` (`MovementManager.UseTime()` from the per-tick update paths,
followed by `PositionManager.UseTime()`).
- `set_on_walkable` (`:3791-3814`): OnWalkable rising edge → `MovementManager.HitGround()`, falling
edge → `LeaveGround()`. Additional LeaveGround sites `:1243`, `:2242`, `:2911` (clear_transient_states
and friends).
- `cancel_moveto()` (`:2143-2147`): → `MovementManager.CancelMoveTo(ActionCancelled)`. Called from
MotionInterp on every user-motion (`DoInterpretedMotion :123-124`, `StopMotion :371-372`,
`StopCompletely :305`, `jump :714`, `move_to_interpreted_state :794`) — i.e. any fresh motion with
the CancelMoveTo param bit set kills the active MoveTo.
- `HandleUpdateTarget(targetInfo)` (`:637-644`): forwards to MovementManager AND PositionManager.
- Teleport: `:4036-4037``CancelMoveTo(WeenieError.ITeleported)`.
- `StopCompletely(bool)` (`:1546-1551`): builds `MovementStruct(StopCompletely)`
MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto —
recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs
InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie).
- `set_target(ctx, objID, radius, quantum)` (`:3947`), `clear_target()` (`:2231`),
`get_target_quantum()` (`:2638`, returns `TargetManager.TargetInfo.Quantum` or 0),
`set_target_quantum(q)` (`:3955`), `unstick_from_object()` (`:4134` → PositionManager.Unstick).
**TargetManager / TargetInfo** (`Physics/Managers/TargetManager.cs`, `Physics/Combat/TargetInfo.cs`):
`SetTarget` clears old target, registers this obj as a voyeur on the target
(`target.add_voyeur(ID, radius, quantum)`); target movement beyond `radius` from last-sent →
`SendVoyeurUpdate``voyeurObj.receive_target_update``TargetManager.ReceiveUpdate`
`PhysicsObj.HandleUpdateTarget` → MoveToManager.HandleUpdateTarget. `TargetStatus`:
`Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6`.
`HandleTargetting` runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole
subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead).
**MotionInterp seams used by MoveToManager:** `adjust_motion(ref motion, ref speed, holdKey)`
(`MotionInterp.cs:394-428` — WalkBackwards→WalkForward·BackwardsFactor, TurnLeft→TurnRight·1,
SideStepLeft→SideStepRight·1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→
apply_run_to_command), `DoInterpretedMotion`, `StopInterpretedMotion`. These are R3-ported in
acdream's `MotionInterpreter` already.
---
## 4. ACE-ism / oddity flags for the R4 retail cross-check
Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above.
- **A1 — stale-member UseFinalHeading read in MoveToPosition** (`MoveToManager.cs:214`).
ACE reads `MovementParams.UseFinalHeading` (the PREVIOUS move's stored params — the new ones
aren't assigned until `:222`); retail 0052a33b reads the ARGUMENT's flag (`arg3->__inner0 & 0x40`).
RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read.
- **A2 — InitializeLocalVars zeroes the wrong field** (`:68`). ACE: `MovementParams.DistanceToObject = 0`.
Retail 0052925b: `movement_params.__inner0 = 0` (the FLAGS word) + `context_id = 0`.
RETAIL-VERIFIED transposition.
- **A3 — InitializeLocalVars omits distance resets**. Retail 0052926c/0052928a also set
`previous_distance = original_distance = FLT_MAX`; ACE leaves them stale (masked because
BeginMoveForward reseeds them). RETAIL-VERIFIED.
- **A4 — TurnToHeading missing BeginNextNode** (`:284-304`). Retail 0052a76d calls
`BeginNextNode()` immediately after queuing the node; ACE returns without it (turn starts one
UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading).
RETAIL-VERIFIED control-flow divergence.
- **A5 — CleanUpAndCallWeenie weenie callback** (`:728`). `WeenieObj.OnMoveComplete(status)` is an
ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is
CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's
`AutoWalkArrived`-style completion notification belongs architecturally — but retail does NOT
notify anything here. RETAIL-VERIFIED.
- **A6 — HandleMoveToPosition aligned-branch `set_heading` snap** (`:444-446`). ACE-only, self-labeled
"custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band —
the residual alignment is finished by the normal per-tick turn integration. acdream runs at
client tick rates → do NOT port the snap. RETAIL-VERIFIED absent.
- **A7 — `AlwaysTurn` field** (`:35`, used `:520`). ACE-only override of the IsAnimating gate in
BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent.
- **A8 — HandleMoveToPosition `inRange` block** (`:463-473`). ACE-only ("custom for low monster
update rate"): when `!UseSpheres`, arrival also fires if `dist < 1.0 && PreviousDistance < dist`
(distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside
CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port.
- **A9 — (non-flag) arrival stop order** — ACE pops the node, stops CurrentCommand, zeroes it, stops
aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it.
- **A10 — BeginTurnToHeading missing empty-head cancel**. Retail 00529bad treats
`pending_actions.head == null` the same as physics-obj-null → `CancelMoveTo(8)`; ACE would throw
on `PendingActions[0]` instead (unreachable through current callers, but port the retail guard).
RETAIL-VERIFIED.
- **A11 — CleanUpAndCallWeenie NPE asymmetry** (`:721-729`). `PhysicsObj` is null-checked,
`WeenieObj` is not — the parameterless `MoveToManager()` ctor leaves WeenieObj null → NPE on any
cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved
client-side, but don't transcribe.
- **A12 — UseTime gate negation** (`:775`). ACE: `TopLevelObjectID != 0 || MovementType != Invalid || Initialized`.
Retail 0052a7b4: `top_level_object_id == 0 || movement_type == Invalid || initialized != 0`.
The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's).
RETAIL-VERIFIED boundary inversion.
- **A13 — default CanCharge flag**. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is
0x1EE0F (CanCharge clear). RETAIL-VERIFIED.
- **A14 — Default_WalkRunThreshold = 1.0f** vs retail 15.0f (0x005243b5). ACE deliberately retuned
(15.0f left commented out at `MovementParameters.cs:49-50`). acdream already treats 15 m as the
retail/wire default (#77 work). RETAIL-VERIFIED.
- **A15 — get_command drops the CanCharge fast-path**. Retail 0052aa95: `flags & 0x10`
unconditional `HoldKey_Run` BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade.
A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both.
RETAIL-VERIFIED (and consistent with `memory/feedback_autowalk_cancharge_bit.md`).
- **A16 — server-tick constants to re-check at 30 Hz.** ACE runs MoveToManager on its own cadence;
the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum
hysteresis) are retail-identical (verified), so no rescaling is needed — but the *feel* of the
aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks.
### BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C)
- `HandleMoveToPosition` chase-arrival branch (00529f8f `test ah,0x41`) renders INVERTED — the flee
branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at
`dist <= distance_to_object`. See §1 HandleMoveToPosition note.
- `BeginTurnToHeading` turn-direction branches (00529bf4/00529c23 `test ah,0x41`/`0x5`) — senses
garbled; ACE's ≤180→TurnRight reading is correct.
- `heading_diff` (0x00528fb0) — x87 return garble; the `motion != TurnRight → 360 - result` mirror
is invisible in the pseudo-C. Verify once via Ghidra MCP before porting.
- `get_command`'s first flag read is BN-rendered as a bogus `(int16_t)` pointer deref of the flags
word — it's just `flags & 0x200` / `& 0x100` on byte 1.
### Negative results (look like ACE-isms, are retail-faithful)
- **FailProgressCount is vestigial in RETAIL too** — grep shows only writes (reset/increment) at
005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere.
Do not invent a cancel-after-N-failures mechanism.
- **Double adjust_motion** (_DoMotion + DoInterpretedMotion-internal) — retail does the same.
- **TurnToObject desired-heading clobber** (write CurrentTargetPosition → Internal overwrites and
reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim.
- **BeginTurnToHeading storing a DIFF into PreviousHeading** — retail 00529c82 does the same.
- **PerformMovement returning success unconditionally** — retail returns 0 too.
- **Error codes all line up** (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB,
ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against
both retail immediates and ACE's WeenieError enum.
---
## 5. acdream R4 blast radius — what exists today
All paths worktree-relative (`.claude/worktrees/vigorous-joliot-f0c3ad`).
### `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (341 lines, static)
Per-tick steering for **server-controlled remotes** during mt 6/7. Approximates
`HandleMoveToPosition`'s aux-turn + arrival only:
- `Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient)`
(`:170-254`): horizontal distance; arrival `moveTowards ? dist <= distanceToObject + ε : dist >= minDistance ε`
(`ArrivalEpsilon = 0.05` — acdream fudge, not retail); heading via quaternion yaw; **±20° snap-to-heading**
(`HeadingSnapToleranceRad`, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band);
otherwise rotate at `TurnRateRadPerSec = π/2`.
- Constants: `RunTurnFactor = 1.5` (retail run_turn_factor @ 0x007c8914), `BaseTurnRateRadPerSec = π/2`,
`TurnRateFor(running)`, `StaleDestinationSeconds = 1.5` (acdream-only staleness guard — replaces
retail's TargetManager re-tracking), `ClampApproachVelocity` (`:296-329`, acdream-only overshoot clamp),
`OriginToWorld` (`:263-277`, landblock-local → render world).
- ⚠ Its class-level doc (`:53-57`) still claims "ACE swaps the chase/flee arrival predicates vs
retail" — WRONG, contradicted by its own later comment (`:186-199`) and by this document. Fix the
doc comment during R4.
- What it deliberately skips (doc `:44-51`): target re-tracking, Sticky/StickTo, fail-distance +
progress detector, cylinder-sphere distance — the R4 port's main new surface.
### `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (88 lines, static)
Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand):
`PlanMoveToStart(moveToSpeed, runRate, canRun)``BeginMoveForward → get_command → adjust_motion`
collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic
at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). `PlanFromVelocity` (fallback from
observed velocity; `StopSpeed=0.20`, `RunThreshold=1.25` — acdream heuristics, not retail).
R4 replaces this seeding with real `MovementParameters.get_command`.
### `src/AcDream.App/Input/PlayerMovementController.cs` — B.6 auto-walk block (KEEP-LIST until R4)
- Fields `:264-296`: `_autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/
_autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning`; turn-direction tracker
`_autoWalkTurnDirectionThisFrame` (`:323`); `IsServerAutoWalking` (`:308`); `AutoWalkArrived` event
(`:340`, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround).
- `BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge)` (`:452-487`):
installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches
retail A15 semantics relayed by ACE's server-side `Creature.SetWalkRunThreshold`).
- `EndServerAutoWalk(reason)` (`:495-503`): idempotent; fires `AutoWalkArrived` on `reason=="arrived"`.
- `DriveServerAutoWalk(dt, input)` (`:567-790`) — the local-player approximation of
HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel;
horizontal distance; arrival `moveTowards ? dist <= distanceToObject : dist >= minDistance + ε`
**gated on 5° alignment** (acdream-only: retail fires arrival purely on distance; the alignment
gate exists because ACE rotates server-side before the Use callback); continuous turn at
`TurnRateFor(_autoWalkInitiallyRunning)` with no snap (deliberately retail-er than
RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the
discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving);
turn-in-place phase issues `DoMotion(Ready)` + zeroes horizontal velocity; forward phase issues
`DoMotion(WalkForward, runRate|1.0)` + `set_local_velocity` from `get_state_velocity`.
R4 replaces this whole function with real pending-node MoveToManager driving.
- Call site: `Update` `:896` (`autoWalkConsumedMotion` skips the user-input motion/velocity blocks);
`:1028` and `:1528` carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover).
### `src/AcDream.App/Rendering/GameWindow.cs` — MoveTo packet handling
- Inbound UM seeding `:4401-4435`: when `update.MotionState.IsServerControlledMoveTo` and
ForwardCommand absent → `ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate,
MoveToCanRun)` provides `fullMotion/speedMod` for the animation funnel.
- Local player `:4507-4547`: mt 6/7 with `MoveToPath` payload → `OriginToWorld`
`_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge)`.
Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (`:4534-4546`,
trace-verified ACE behavior).
- Remotes `:4570-4610`: `remoteMot.ServerMoveToActive = IsServerControlledMoveTo`; path payload →
`MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/
LastMoveToPacketTime`; non-MoveTo UM clears `HasMoveToDestination`.
- Remote per-tick `:9594-9682`: staleness guard (1.5 s) → else `RemoteMoveToDriver.Drive`
orientation write; Arrived → zero velocity; Steering → `rm.Motion.apply_current_movement` +
`ClampApproachVelocity`. MoveTo-active-but-no-path → hold velocity zero (882a07c stance).
- Speculative install `:12122-12164`: `InstallSpeculativeTurnToTarget` — local prediction of ACE's
MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule
(`AceCanChargeDistance = WalkRunThreshold/2`); per-type use radius (0.6 default / 3.0 creature /
2.0 door-lifestone-portal-corpse).
- `:4942`: second `PlanMoveToStart` consumer (same seeding on another path — CreateObject-time).
### Wire layer
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`: parses mt 6/7 union → `CreateObject.MoveToPathData`
(`:157`, `:287-344`); exposes `IsServerControlledMoveTo` (mt is 6 or 7), `MoveToCanRun`
(params bit 0x2), `MoveTowards` (0x200), `CanCharge` (0x10), `MoveToSpeed`, `MoveToRunRate`.
- `src/AcDream.Core.Net/Messages/CreateObject.cs:292-302`: `MoveToPathData(TargetGuid, OriginCellId,
OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading)` —
the full retail `MovementParameters` float block is already captured on the wire; FailDistance and
WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers).
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`: `ProbeAutoWalkEnabled` (`ACDREAM_PROBE_AUTOWALK`)
gates `[autowalk-mt]/[autowalk-begin]/[autowalk-end]` traces.
### What R4 must reconcile
Today there are **three independent approximations** of one retail mechanism: DriveServerAutoWalk
(local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart
(animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical),
driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as
`MotionInterpreter`). The wire already delivers every field the retail manager needs
(MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/
MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade +
fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the
TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness
guard — decide in R4 whether that substitution stays as a documented divergence-register row).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,452 @@
# R4 port work-list — MoveToManager verbatim + the three-approximation cutover
Inputs: `r4-moveto-decomp.md` (verbatim retail extraction, same dir),
`r4-ace-moveto.md` (ACE cross-ref + blast radius, same dir), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R4), R3 pins
`docs/research/2026-07-02-r3-motioninterp/W0-pins.md` (A4 MovementParameters masks —
used as-is), current code `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340
lines, static), `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines),
`src/AcDream.App/Input/PlayerMovementController.cs` (1604 lines, post-R3-W6; B.6
auto-walk block :275-:766 + :896), `src/AcDream.App/Rendering/GameWindow.cs`
(local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick
:9594-9682, speculative install :11915/:12004/:12122),
`src/AcDream.Core.Net/Messages/UpdateMotion.cs` (mt 6/7 parse :252-356),
`src/AcDream.Core/Physics/Motion/MovementParameters.cs` (R3-W1, flags+scalars only),
`src/AcDream.Core/Physics/MotionInterpreter.cs` (R3-complete: DoInterpretedMotion
:2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118,
InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585).
**Precondition / state at R4 start:** R1/R2/R3 SHIPPED (R3 through W6 `fb7beb70`;
R2+R3 share one pending visual pass — R4 code work does not block on it). The
R3 seams R4 consumes all exist: `DoInterpretedMotion(uint, MovementParameters)` /
`StopInterpretedMotion(uint, MovementParameters)` / `adjust_motion(ref,ref,HoldKey)`
(the entire retail `_DoMotion` shape), `MotionsPending()`, `TransientStateFlags.Contact`
on `PhysicsObj`, `HitGround`/`LeaveGround` (W4), the `InterruptCurrentMovement`
no-op `Action?` seam (register TS-36 — "R4 is where cancel_moveto lands"), and
`MovementParameters` with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge
false). What does NOT exist: any MoveToManager, the command-selection family
(`get_command`/`towards_and_away`/`get_desired_heading`), MovementType values
0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any
heading/cylinder-distance helper in Core. MovementManager itself is **R5**
R4 binds MoveToManager to the interp directly and keeps type-dispatch at the
existing call sites (same "null-guarded relay inserted later without behavior
change" pattern R3 §4 used for MotionDone).
---
## 0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| P1 | **ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine.** Retail `MovementManager::unpack_movement` (0x00524440 @300566-300598) calls `CPhysicsObj::interrupt_current_movement` (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (`launch-slice2.log`; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). | decomp §2f head vs GameWindow:4534-4546 trace note; ACE `Player.OnMoveComplete`/MoveToChain broadcast code | (a) Re-capture the echo with `ACDREAM_PROBE_AUTOWALK=1` + `ACDREAM_DUMP_MOTION=1` and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: **suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's `current_command` (post-adjust) while `is_moving_to()`** — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a). |
| P2 | **`get_desired_heading` (0x0052aad0) 180/0 constants.** BN body is an x87 setcc garble (decomp §5e); ACE gives `WalkForward\|RunForward → movingAway ? 180 : 0`, `WalkBackwards → movingAway ? 0 : 180`, default 0. The command GROUPING is visible in the BN text; the constants are not. | decomp §5e vs ACE MovementParameters.cs:186-198 | One Ghidra MCP decompile of `0x0052aad0` (`/decompile_function?address=0x0052aad0`) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc. |
| P3 | **`heading_diff` (0x00528fb0) third-arg mirror — the two research docs CONTRADICT.** r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has `if (result > EPSILON && motion != TurnRight) result = 360 - result` and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: `HandleTurnToHeading` @0052a1bc passes the LIVE `current_command` (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360raw; without it, the progress window `(ε, 180)` reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the `previous_heading` update path differs). | decomp §5g vs ACE MoveToManager.cs:817-828 | Ghidra decompile of `0x00528fb0` — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on `acclient!heading_diff` logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned. |
| P4 | **TargetManager scope — what feeds `HandleUpdateTarget` for type 6/8.** Retail: `set_target` registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via `receive_target_update``HandleUpdateTarget` (decomp §9f; ACE `TargetManager.cs` with a 0.5 s `HandleTargetting` cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, `initialized` never flips and type-6/8 moves never start (UseTime gate §6a). | decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) | DECISION (made here, confirm in V0): R4 ships a **minimal App-side `TargetTracker` adapter**, not a TargetManager port: on `set_target(0, tlid, 0.5, 0)` GameWindow's entity table delivers one immediate `TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}`, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); `clear_target` unsubscribes; despawn/teleport of the target delivers `status=ExitWorld/Teleported` (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). `set_target_quantum` is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting `TargetManager::SetTarget/ReceiveUpdate/HandleTargetting` from the raw (grep `TargetManager::` — est. ~200 lines) and porting verbatim in V4 instead. |
| P5 | **Position::heading convention + coordinate space.** MoveToManager consumes `Position::heading(from, to)` (degrees), `Frame::get_heading/set_heading`, `Position::distance`, `Position::cylinder_distance`. acdream has NO Core heading helper (only `SceneryHelpers.SetHeading`); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire `DesiredHeading` is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via `OriginToWorld`, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or `use_final_heading` faces the wrong way. | acclient Position::heading (not in this extraction); `RemoteMoveToDriver.OriginToWorld` :263-277; L.2b outbound heading packers (already convert yaw→AC heading) | Grep the named raw for `Position::heading` + `Frame::get_heading` and pin the formula (expected: heading = fmod(450 atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips. |
| P6 | **TurnToObject (mt 8) wire layout.** Decomp §2f case 8 reads `object_id` dword, then a `wire_heading` dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes `desired_heading = wire_heading` and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. | decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer | Read ACE's `MoveToObject/TurnToObject` GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes). |
| P7 | **Remote contact for the UseTime gate.** `MoveToManager::UseTime` gates on `transient_state & 1` (CONTACT). Remotes' `PhysicsObj.TransientState` is maintained well enough that R3's funnel `contact_allows_move` works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. | MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) | Code read of the remote PhysicsBody state writers + one `ACDREAM_DUMP_MOTION` smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate. |
**V0 also commits the research docs into the repo**
(`docs/research/2026-07-03-r4-movetomanager/`: the decomp extraction, the ACE
cross-ref, this plan, and a `V0-pins.md` in the W0-pins format), and fixes the
two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57
("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1;
the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch.
**V0 cdb capture (optional, non-blocking):** all seven pins are textually
resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens
anyway: bp `MoveToManager::PerformMovement` / `BeginMoveForward` /
`BeginTurnToHeading` / `HandleMoveToPosition` / `HandleUpdateTarget` /
`MovementParameters::get_command` (args+ret) while the user runs: use a door at
range (MoveToObject + arrival), use while facing away (TurnToHeading node first),
let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead
counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic +
ACE-writer fixtures suffice without it.
---
## 1. ITEMIZED GAPS — current vs retail (R4 scope)
Severity: **BLOCKER** = stage deliverable impossible without it; **HIGH** =
visible behavior wrongness / blocks R5-R6; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| M1 | **No MoveToManager exists** — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no `pending_actions` node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: `DriveServerAutoWalk` (local player), `RemoteMoveToDriver.Drive` (remotes), `ServerControlledLocomotion.PlanMoveToStart` (animation seed). | struct acclient.h:31473; whole extraction §§1-7 | RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 | **BLOCKER** |
| M2 | **Command-selection family absent**: `get_command` (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk \|\| distdto > threshhold 15) → Run else walk), `towards_and_away` (0x0052a9a0 — WalkBackwards inside the min band), `get_desired_heading` (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). `PlanMoveToStart` has NO distance logic at all. | decomp §5c/§5d/§5e | PlayerMovementController.cs:452-487 (`_autoWalkInitiallyRunning` one-shot), :701-706; ServerControlledLocomotion.cs:35-53 | **BLOCKER** |
| M3 | **No node plan** (`[TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]?` with `BeginNextNode` stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the `use_final_heading` (0x40) node is entirely missing — wire `DesiredHeading` is parsed and thrown away. | §3c/§4a/§4b/§6f | PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) | **BLOCKER** |
| M4 | **No aux-command steering**: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through `_DoMotion` when \|diff\| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (`RemoteMoveToDriver` π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. | HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) | RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 | **HIGH** |
| M5 | **Arrival predicate approximated + wrong distance metric**: retail `GetCurrentDistance` uses `cylinder_distance(own r/h, target r/h)` when `use_spheres` (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's `max(minDistance, distanceToObject)` hack + an invented `ArrivalEpsilon 0.05` + the AD-26 facing gate. Retail: `moving_away ? dist ≥ min_distance : dist ≤ distance_to_object`, distance-only. | GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) | RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) | **HIGH** |
| M6 | **No progress detector / fail distance**: `CheckProgressMade` (1 s window, 0.25 units/s incremental AND overall), `fail_distance` → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into `MoveToPathData` without a consumer. | CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 | RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused | **HIGH** |
| M7 | **Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end** — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". | unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g | UpdateMotion.cs:252 (`movementType is 6 or 7` — no 8/9 branch) | **HIGH** |
| M8 | **Target re-tracking absent** (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into `MoveToPathData.TargetGuid` and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). | HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams | RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) | **HIGH** (P4 pins the R4 answer) |
| M9 | **TS-36 dangling**: `InterruptCurrentMovement` is a no-op Action — `jump()` (:921/:1833 sites), `StopCompletely` (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. | interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c | MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 | **BLOCKER** for V5 |
| M10 | **PerformMovement discipline absent**: every new moveto must CancelMoveTo(0x36) + `unstick_from_object` FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo\* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's `BeginServerAutoWalk` just overwrites fields. | PerformMovement §3a; §3b-§3e | PlayerMovementController.cs:452-487 | **HIGH** |
| M11 | **MovementType/MovementStruct too narrow**: enum lacks `Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`; MovementStruct lacks `ObjectId/TopLevelId/Pos/Radius/Height/Params`. | MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 | MotionInterpreter.cs:101-113 (1-5 only), :408-422 | MED (mechanical) |
| M12 | **WeenieError lacks the moveto codes**: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. | codes §7c/§7a; A10 table | MotionInterpreter.cs:136-205 | LOW |
| M13 | **`my_run_rate` not fed from the MoveTo wire**: retail unpack cases 6/7 write `minterp->my_run_rate = read_float()`; acdream parses `MoveToRunRate` but only feeds the PlanMoveToStart seed — the interp's `MyRunRate` (:585) never sees it, so `apply_run_to_command`'s speed scale during a moveto uses stale rate. | unpack @300603/@300660 | UpdateMotion.cs:342; GameWindow.cs:4415 | MED |
| M14 | **Sticky discipline absent**: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (`&= 0xffffff7f`); BeginNextNode's empty-queue sticky branch hands off to `PositionManager::StickTo(tlid, radius, height)` — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (`_motionFlags` dead at UpdateMotion.cs:135). | §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d | nothing; UpdateMotion.cs:135 | MED (seam now, body R5) |
| M15 | **Heading/geometry helpers absent in Core**: `Position::heading`, `heading_diff` (P3), `heading_greater`, `Frame::get/set_heading`, `cylinder_distance`. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. | §5f/§5g/§5a; P5 | SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw | **BLOCKER** (mechanical) |
| M16 | **`PlanFromVelocity` invented heuristics carry NO register row** (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. | none (that's the problem) | ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 | MED (bookkeeping, fix in V4) |
| M17 | **Speculative use-turn bypasses the (future) manager**: `InstallSpeculativeTurnToTarget` (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local `CPhysicsObj::TurnToObject/MoveToObject` through the SAME manager (§9a/§9b callers). | §9a/§9b | GameWindow.cs:11915/:12004/:12122-12164 | MED (rewire in V5) |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `MovementParameters` flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes | ctor 0x00524380; W0-pins A4 | `Motion/MovementParameters.cs` — V1 EXTENDS with get_command family; no field changes |
| The entire `_DoMotion`/`_StopMotion` seam target: `adjust_motion(ref,ref,HoldKey)` + `DoInterpretedMotion`/`StopInterpretedMotion(uint, MovementParameters)` incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") | §7a/§7b; 0x00529010/0x00529080 | MotionInterpreter.cs:1241/:2861/:3016 — untouched |
| WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values | A10 table | MotionInterpreter.cs:136-205 |
| mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session | UnPackNet 0x0052ac50 §2g | UpdateMotion.cs:280-356 + `CreateObject.MoveToPathData` — V3 adds mt 8/9 beside it |
| `OriginToWorld` (landblock-local origin → streaming world) | Position rebase equivalence (P5) | RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file) |
| MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate | 0xF74C dispatch pc:357214 | GameWindow OnLiveMotionUpdated head |
| R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work | R2/R3 shipped | Motion/ classes + MotionTableDispatchSink |
| Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's `transient_state & 1` gate reads the same source `contact_allows_move` uses (P7 confirms) | @307781 | MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState |
| HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) | MovementManager::HitGround 0x00524300 | MotionInterpreter.cs HitGround/LeaveGround + their App call sites |
| Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 | — | PlayerMovementController.cs sections 4-8 |
| AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new `MoveToComplete` seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) | §7e | GameWindow.cs:11915 subscription; register AD-27 |
| AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored | ACE Player_Move.cs:66 | GameWindow.cs:12102-12164; register AP-23 |
| `PlanFromVelocity` (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only `PlanMoveToStart` dies in R4 | retire R6 (per-tick order) | ServerControlledLocomotion.cs:54-87 |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (+
`MoveToMath.cs` for the free functions) per plan rule 4 — pure logic, GL-free,
seams only. NAME WATCH: retail's `MoveToManager::MovementNode {type, heading}`
must NOT collide with R2's `Motion/MotionNode.cs` (the pending_motions node) —
name it `MoveToNode`, register-note the rename. Tests:
`tests/AcDream.Core.Tests/Physics/Motion/`. Every commit: build+test green,
register rows added/retired in-commit.
**V0 — pins + research docs (docs only).**
Commit `docs/research/2026-07-03-r4-movetomanager/` (decomp extraction, ACE
cross-ref, this plan, `V0-pins.md` resolving P1-P7 in the W0-pins format —
P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6
are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale
claim in the same commit. Deps: none.
**V1 — command-selection family + state widening (Core, no consumers).** (closes M2-mechanics, M11, M12, M15)
`MovementParameters` gains `GetCommand(dist, heading, out motion, out holdKey,
out movingAway)` (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped
— A13+A15 trap: retail's version of BOTH), `TowardsAndAway` (§5d),
`GetDesiredHeading` (P2 pin), plus a `FromWire(uint bitfield, dto, min, fail,
speed, threshhold, desiredHeading)` factory (UnPackNet semantics — bit masks per
A4) and the field-by-field copy the entry points do. `MoveToMath.cs`: `HeadingDiff`
(P3 pin), `HeadingGreater` (§5f), `PositionHeading` + `GetHeading/SetHeading`
(P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention
in the codebase), `CylinderDistance` (§5a signature). `MovementType` gains
Invalid/6/7/8/9; `MovementStruct` gains `ObjectId/TopLevelId/Pos(world+cell)/
Radius/Height/Params`. `WeenieError` += 0x0B/0x36/0x37/0x38/0x3D.
Tests first: get_command truth table (all four flag quadrants × distance bands ×
the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <,
walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon
(0.000199999995f literal) tables, cardinal-heading goldens (P5).
Fixture source: **synthetic + A4/A10 pins**. Deps: V0.
**V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring).** (closes M1/M3/M4/M5/M6/M10/M14-core)
All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags
word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3)
/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/
MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading
(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/
TurnToObject_Internal (fmod, sought-heading read); node factories + `MoveToNode`
(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads
radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward
(localParams: clear 0x8000, holdKey from get_command, write-back to stored
params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8)
per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim);
UseTime (retail gate polarity: `tlid == 0 || type == Invalid || initialized`
NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6
set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min
: dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam);
HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true)
+ pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes,
FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates);
GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux,
clear_target gate, InitializeLocalVariables — does NOT drain nodes);
CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering
+ the `MoveToComplete` seam, §4); CancelMoveTo (drain + CleanUp + Stop; the
WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust
then dispatch); is_moving_to; fail_progress_count as a write-only field (§8).
Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors,
own+target radius/height, IsInterpolating, Contact, set_target/clear_target/
quantum, unstick, StickTo, MoveToComplete.
Tests first: per-function conformance tables from the extraction's constants
inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie →
interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on
Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object;
BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6
stall); a scripted end-to-end table drive (positions fed per tick → expected
node pops + dispatched motion ids/hold keys, incl. run→walk demote inside
threshold 15).
Fixture source: **synthetic + §12 constants (+ V0 cdb goldens if captured)**.
Deps: V1.
**V3 — wire completion: mt 8/9 + my_run_rate + params exposure.** (closes M7, M13, M14-wire-note)
UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and
mt 9 (3-dword UnPackNet) into a widened `MoveToPathData` (or a sibling
`TurnToPathData`) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL
UnPackNet fields reach the consumer as a `MovementParameters` via `FromWire`
(today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid
trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer
cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement
item, not consumed here. `MoveToRunRate` documented as the `MyRunRate` write
the V4/V5 consumers perform (unpack @300603).
Tests first: golden-byte fixtures generated from ACE's event writers (P6) for
all four types + flag permutations; existing mt 6/7 fixtures green unchanged.
Fixture source: **ACE-writer golden bytes**. Deps: V1 (types), parallel with V2.
**V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED.** (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row)
Each `RemoteMotion` gains a `MoveTo` manager bound to its existing
`Motion` interp + body (construction beside the R3 sink bind, §4). GameWindow
remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table →
`MovementStruct{MoveToObject, radius/height from the entity's setup}`
`MoveTo.PerformMovement`; unresolvable → degrade to MoveToPosition(wire origin)
per §2f; mt 7/8/9 likewise; `Motion.MyRunRate = MoveToRunRate`. The P4
`TargetTracker` adapter feeds `HandleUpdateTarget` (register row). Per-tick: the
:9594-9682 block becomes `rm.MoveTo.UseTime()` (same slot the legacy driver
occupied — see the placement decision below); remote HitGround sites add
`rm.MoveTo.HitGround()` after `rm.Motion.HitGround()` (§2d order). DELETE:
`RemoteMoveToDriver.cs` (OriginToWorld moves to `MoveToMath`; TurnRateFor's
surviving consumers, if any outside the deleted paths, get the constant from the
interp's own apply_run_to_command home), `PlanMoveToStart` + its :4412 seeding
branch (the manager's own `_DoMotion` → funnel sink now produces the cycle,
identical mechanism to every other interpreted motion), the
`HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/
ServerMoveToActive` RemoteMotion fields and their :4917/:5329 reads.
Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker
minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for
PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses
retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop
distance in the visual pass).
Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving
a manager against a mocked tracker; dispatched-motion trace conformance (NPC
chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table);
existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION.
Fixture source: **V2 harness + live smoke**. Deps: V2+V3.
**V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices).** (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation)
`PlayerMovementController` gains a `MoveTo` manager bound to its `Motion` interp
(exposed like `Motion` was for R3-W2's bind). GameWindow local routing
(:4507-4547): the `BeginServerAutoWalk` call becomes MovementStruct →
`MoveTo.PerformMovement` (OriginToWorld unchanged); the P1 pin governs the mt-0
companion echo (adaptation row if that's the pin). `Motion.InterruptCurrentMovement`
binds to `MoveTo.CancelMoveTo(ActionCancelled)` — TS-36 RETIRED; user input now
cancels the moveto exactly the retail way (input edge → DoMotion with
CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit
"user-input cancel" check dies with the block. DELETE the whole B.6 block:
fields :275-:340 (incl. `IsServerAutoWalking`, `_autoWalkTurnDirectionThisFrame`
+ its :1528-1540 consumer), `BeginServerAutoWalk` :452, `EndServerAutoWalk` :495,
`DriveServerAutoWalk` :567-766, the :896 call + `autoWalkConsumedMotion` skip
plumbing (:1028). `AutoWalkArrived` is replaced by the `MoveToComplete(None)`
seam subscription (AD-27 re-anchored, same Use-resend behavior).
`InstallSpeculativeTurnToTarget` (:12122) rewires to a LOCAL
`MovementStruct{TurnToObject/MoveToObject}` through the player's manager
(retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius
source, row re-anchored). Per-tick: `MoveTo.UseTime()` at the :896 slot
(placement decision below). `[autowalk-*]` probes retarget to the manager
(PhysicsDiagnostics name kept).
Tests first: full suite green; controller edge-driven suites unchanged;
manager-driven use-walk scenario (turn node → walk → demote-to-walk near target
→ arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto
cancels it — TS-36's predicted failure); outbound golden-byte parity for a
scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues
non-autonomous motions, so outbound autonomous traffic must NOT change).
**ONE user visual pass** (with R2/R3's if still pending): use a door at range
(walk-up + arrival + door opens once), use while facing away (visible turn
first), NPC chase legs (turn cycles during corrections, walk-pace close-in),
TurnTo emote-target if reachable.
Fixture source: **pre-cutover traces + golden-byte + V2 harness**. Deps: V4.
**V6 — register sweep + roadmap + digest (docs/cleanup only).**
Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added:
TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned),
MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows
(→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27,
AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped);
milestones check; memory digest note (animation deep-dive: MoveToManager gap
CLOSED; the "three approximations" pattern retired).
Deps: V5.
Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5
sequential (remotes prove the manager before the local cutover). V4 and V5 both
touch GameWindow — single-agent each.
**DECISION — B.6 auto-walk dies in R4 (V5), not R5.** Grounds: the plan of
record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate
is delete-in-stage; everything DriveServerAutoWalk does is a subset of the
verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band =
retail's 20° aux band; arrival = the distance predicate; user cancel = the
cancel_moveto interrupt chain); keeping it alongside a real manager would mean
two writers on the same motion channels — the exact double-motion hazard TS-36
warns about. What survives R5-ward is only the two seams the manager can't fill
yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row).
**DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots.**
`MoveTo.UseTime()` runs (a) for the player: in `PlayerMovementController.Update`
at the exact point `DriveServerAutoWalk` runs today (:896, before the
input-driven motion block); (b) for remotes: in the GameWindow per-remote tick
block where `RemoteMoveToDriver.Drive` runs today (:9594). Justification:
least-invasive — both slots already sit at the "after inbound wire, before/with
physics integrate" altitude the legacy approximations were tuned for, so the
cutover changes WHAT steers, not WHEN, and every behavior diff in the visual
pass is attributable to the manager itself rather than to a reordering. Retail's
true slot (`UpdateObjectInternal`: … transition sweep → MovementManager.UseTime →
PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers
at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement
provisional until R6"). The provisional placement gets a one-line note in both
call sites, not a register row (ordering-within-tick is R6's audited scope).
---
## 4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing)
Retail chain being stood in for (R5 inserts MovementManager as a null-guarded
relay WITHOUT behavior change — same rule as R3 §4's MotionDone note):
```
retail: unpack_movement / CPhysicsObj entry points
└─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager)
└─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp
(adjust_motion → DoInterpretedMotion/StopInterpretedMotion)
R4: GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch)
├─ mt 0 → funnel (unchanged; P1 pin governs the local-player echo)
├─ mt 1-5 → interp paths (unchanged)
└─ mt 6/7/8/9 → entity.MoveTo.PerformMovement(MovementStruct) [+ MyRunRate write]
```
**Construction & binding (per entity, at the same site as the R3 sink bind):**
- Remote: where `RemoteMotion.Motion` + the MotionTableManager sink are wired
(GameWindow entity creation), construct `MoveTo = new MoveToManager(...)` with:
- `interp` = `remoteMot.Motion` (the `_DoMotion` target — adjust_motion +
DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never
calls DoMotion/set_hold_run/raw-state APIs — decomp §7b),
- `stopCompletely` = `() => Motion.StopCompletely()` (retail routes
CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct
call is the same body pre-R5),
- body accessors: position+cell, `GetHeading`/`SetHeading(deg, send)` (P5
convention; `send` flags the outbound heading snap — remotes: no-op send),
own radius/height (setup shape), Contact (`PhysicsObj.TransientState`),
`IsInterpolating` = InterpolationManager queue-non-empty,
- target seams: `set_target/clear_target` → the P4 TargetTracker;
`get/set_target_quantum` → tracker-recorded value (accepted, non-throttling);
- `unstick``Motion.UnstickFromObject` (existing no-op seam, R5);
`stickTo(tlid, radius, height)` → NEW no-op Action seam (R5 StickyManager;
register row);
- `MoveToComplete(WeenieError)` → remote: nothing (retail-faithful);
local player: the AD-27 Use-resend subscription (fires ONLY on `None`).
- Local player: `PlayerMovementController` constructs/exposes `MoveTo` bound to
its own `Motion`; GameWindow routes local mt 6-9 to it.
**The cancel chain (TS-36 retires here):**
```
user input edge / jump() / StopCompletely
└─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833)
└─ InterruptCurrentMovement?.Invoke() [R3 seam, was no-op]
└─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled)
└─ drain nodes → CleanUp (stops current+aux via _StopMotion,
clear_target, InitializeLocalVariables → movement_type=Invalid)
→ StopCompletely
```
Reentrancy invariant (MUST be tested, V2): the tail `StopCompletely` re-enters
`InterruptCurrentMovement``CancelMoveTo`, which NO-OPS because CleanUp already
reset `movement_type` to Invalid BEFORE the stop (retail ordering §7e; ACE note
r4-ace §3). Do not reorder CleanUp/StopCompletely.
**HandleUpdateTarget feed (P4):** TargetTracker (App) watches the entity table;
delivery → `entity.MoveTo.HandleUpdateTarget(TargetInfo)`; context 0 only
(retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0).
Target despawn → `status = ExitWorld` (manager cancels 0x37/0x38 itself).
**Per-tick + ground events:** `MoveTo.UseTime()` at the two legacy slots
(decision above). Every existing `Motion.HitGround()` call site adds
`MoveTo.HitGround()` AFTER it (retail §2d order: minterp then moveto).
LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not
invent.
**GameWindow mt 6-9 routing detail (V4/V5):**
1. MotionSequenceGate first (unchanged).
2. `Motion.MyRunRate = MoveToRunRate` (unpack @300603/@300660).
3. mt 6: resolve `TargetGuid` in the entity table → top-level parent id (retail
resolves `parent ?: target`, §9a — acdream: container/parent link if the
entity model has one, else the guid itself) + setup radius/height →
`MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height,
Params=FromWire(...)}`. Unresolvable → `MovementStruct{MoveToPosition,
Pos=OriginToWorld(wire origin)}` (the §2f degrade — NOT an error).
4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading =
wire_heading, TurnToHeading (§2f fallback).
6. mt 9: TurnToHeading(FromWire 3-field params).
7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire
discriminator); for remotes: unchanged funnel + (retail head shape) an
interrupt — remotes' UM streams from ACE already interleave moveto and
interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing
under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via
PerformMovement regardless, so remotes are insensitive to the pin either way).
**What the manager dispatches (animation side — zero new work):**
`_DoMotion(0x45000005 WalkForward, localParams{holdKey})` → adjust_motion
promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the
R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire
RunForward takes today. `PlanMoveToStart`'s seeding job ceases to exist rather
than being replaced.
---
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
- **`MoveToManager::LeaveGround` / `ReportExhaustion` — COMDAT-folded no-ops**
in this build (decomp §2e); `CMotionInterp::HandleEnterWorld` likewise. No
members, no seams, no behavior.
- **`fail_progress_count` is write-only in RETAIL too** (§8: 6 sites, zero
reads). NO give-up-after-N mechanism exists — the only aborts are
fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall
timeout to replace AD-9's timer; deleting the timer is the point.
- **`CancelMoveTo`'s WeenieError arg is NEVER READ in the body** (§7c) — every
call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the
parameter (R5 parity + logging), wire no behavior to its value.
- **`CleanUpAndCallWeenie` contains NO weenie call in this build** (§7e — the
name is vestigial; body ≡ CleanUp + StopCompletely). acdream's
`MoveToComplete` seam is a documented CLIENT addition standing in for ACE's
server-side `OnMoveComplete` — do not present it as retail.
- **No listener/observer machinery on MoveToManager or MovementManager**
(grep-negative, §10). `MoveToComplete` is the one seam, added consciously.
- **`CPhysicsObj::cancel_moveto` does not exist** — the cancel entry is
`interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` does not
exist** — position moves enter via unpack case 7 / direct manager call only.
- **Do NOT copy the ACE-isms** (all RETAIL-VERIFIED in r4-ace §4): A1
stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2
InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word),
A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6
set_heading snap in the aligned branch ("custom: sync for server ticrate"),
A7 `AlwaysTurn`, A8 `inRange` arrival block ("custom for low monster update
rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true
default, A14 threshold 1.0, A15 dropped CanCharge fast-path.
- **Do NOT "fix" the retail quirks**: TurnToObject's desired_heading write is
clobbered before any read (final heading = face-the-object; ACE matches);
BeginTurnToHeading stores the remaining DIFF into previous_heading; the
double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement
returning 0 unconditionally (errors surface via CancelMoveTo, not the return).
- **HandleTurnToHeading's `set_heading(node.Heading, true)` snap IS retail**
(0052a146) — the only heading snap in the whole family. Everything else
rotates via dispatched turn motions.
- **BN artifact ledger applies** (decomp §11): the chase-arrival and turn-pick
branch senses in the raw are `test ah,0x41` inversions — the adjudicated
readings (chase arrives at `dist ≤ distance_to_object`; diff ≥ 180 →
TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The
`RemoteMoveToDriver.cs:53-57` "ACE swaps the predicates" doc claim is WRONG
(superseded in-file at :186-199) — fixed in V0.
- **TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo
bodies were NOT extracted** — call shapes only (§9f/§9g). Do not invent
internals beyond the P4 minimal adapter; R5 owns the ports.
- **The older Ghidra chunk `chunk_00520000.c` is from a DIFFERENT build** for
this address region (§10) — function boundaries don't align; unusable for
adjudication. Use the named raw + Ghidra MCP on `patchmem.gpr` only.
- **MovementManager itself is R5** — R4 must not grow a premature facade; the
type dispatch stays at the existing GameWindow/controller call sites, and the
two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly
the relays R5 will absorb.

View file

@ -0,0 +1,103 @@
# R4 live-verify session handoff — fresh-session entry point (2026-07-03, evening)
Successor to `2026-07-03-phase-r-session-handoff.md` (whose postscripts
summarize the same arc — this doc is the current entry point). Worktree
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree CLEAN
at `3c866f95`. Full suite green: **3,963** (+15 since the R4 handoff).
## State
**R4 is SHIPPED and user-verified live** (V0V6 + five root-cause fixes the
live pass exposed). Confirmed working by the user's eyes: local use-walk
(turn-first, walk/run by distance, arrival, door opens once), door open +
door SWING animation, remote (retail-client) close-range moveto walking,
and remote run-distance movetos at correct animation pace AND speed (#160).
## The five live fixes (trail, newest first — each is a real mechanism, no adaptations added)
| Commit | Root cause fixed |
|---|---|
| `41006e79` | **#160 slow-motion remote runs**: remote interps had NO weenie → retail's `apply_run_to_command` rate chain (`weenie ? (InqRunRate() ?: my_run_rate) : 1.0`, raw 305062-305076) took the degenerate 1.0 branch; the wire's `MoveToRunRate` (stored in `MyRunRate` by the mt-6/7 unpack, M13) was never consumed. Fix: `RemoteWeenie` (Core) — retail's per-object ACCWeenieObject stand-in; `InqRunRate` FAILS → `my_run_rate` fallback; `IsThePlayer`=false ends the "null weenie counts as the player" A3 reading. |
| `350fb5e3` | **Door-swing snap** (register TS-40): CMotionInterp's detached-object link-strip guard (`physics_obj->cell == 0`, raw @305627) was proxied by `CellPosition.ObjCellId==0`, seeded only by the local player's `SnapToCell` → every REMOTE body read "detached" and every dispatched transition link was stripped same-tick. Fix: explicit `PhysicsBody.InWorld` set by SnapToCell + RemoteMotion construction. |
| `006cf659` | **Remote-player glide (partial)** + **door UMs dropped**: (a) remote PLAYERS' MoveToManagers were never ticked (the V4 UseTime slot was NPC-only) — extracted `TickRemoteMoveTo`, now also in the grounded player-remote (L.3 M2) pipeline; (b) UP-less entities (doors) never got a RemoteMotion → since the S2b funnel cutover their UM motions were parsed and dropped — rm now created on first UM, with a `LastServerPosTime>0` gate keeping UP-less bodies OUT of the dead-reckoning tick. |
| `24569fd2` | **Moveto stall #2**: login `SetPosition` ran before the DefaultSink bind in `EnterPlayerModeNow` → its StopCompletely A9 node had no completion partner → ONE immortal pending_motions node → wait-for-anims gate never opened. Fix: bind block moved above SetPosition; `LoginQueue_DrainsToEmpty_UnderProductionFeed` pins the invariant. |
| `c2dc1a88` | **Moveto stall #1 (the big one)**: the R3 port MISREAD `StopCompletely_Internal` (0x0050ead0) as a velocity zero — it is `CPartArray::StopCompletelyInternal` (0x00518890) = `MotionTableManager::PerformMovement(type 5)`, the ANIMATION-side stop whose UNCONDITIONALLY-queued entry is the matched pop for the A9 pending_motions node. Fix: `IInterpretedMotionSink.StopCompletely()` → the R2 manager's already-ported type-5 op. Also: retail's per-tick `CheckForCompletedMotions` slot (CPartArray::HandleMovement 0x00517d60) added to the controller tick, and the P1 autonomous-STORE family completed (unpack store 00509730, input-edge stores DoMotion@00510030/StopMotion@005100e0, section-2 stops per-frame stamping, speculative install stores false). |
## The queue model (the load-bearing mental model — do not relearn)
`pending_motions` (interp) pops HEAD-ANY on each `MotionDone`; completions
come from the manager's `pending_animations` entries. **Every enqueue must
have a completion partner** (dispatch → manager entry). One orphan =
permanent ≥1 backlog (later completions just relabel it) = `MotionsPending`
true forever = `BeginTurnToHeading`'s verbatim wait-for-anims gate wedges
every moveto. Orphan producers found: StopCompletely-before-sink-bind and
the misread StopCompletely_Internal. The `[autowalk-gate]` probe prints the
whole gate state — it is how both wedges were pinned.
## OPEN — next session picks up here (full trails in docs/ISSUES.md)
1. ~~**#161 — remote jump landing stuck in the falling pose**~~ **CLOSED
2026-07-03 `b1cf0102`, user live-verified.** The prime suspect was
right; the mechanism was a BN decomp artifact: retail REWRITES the
apply pass's params word (raw 305778 smeared store, mask 0x37ff →
`ModifyInterpretedState=false`; ACE MotionInterp.cs:444-449 confirms),
so retail PRESERVES interpreted fwd through the fall and HitGround
re-dispatches it (the landing-link exit). Also: both landing blocks
cleared Gravity BEFORE `Motion.HitGround()` (whose verbatim state&0x400
gate then no-opped — fixed order, AP-81); K-fix17's SetCycle DID
execute but re-set the clobbered Falling command — both copies deleted.
NOTE: "HitGround raw @305949" below was a mislabel (that line is inside
`move_to_interpreted_state`; real HitGround = 0x00528ac0 →
`apply_current_movement(0,0)`). Full trail: ISSUES Recently-closed #161.
2. ~~**#162 — glide-class adjudication**~~ **CLOSED 2026-07-03, no
adaptation.** User A/B: retail does NOT glide (new-fact branch) — and
post-#161 acdream doesn't either (user-verified walk/run-by-distance +
clean mid-chain key takeover). Mechanism re-audit: head-interrupt +
CancelMoveTo ports are retail-verbatim (retail's observer ALSO loses
the armed moveto on the first reflection); ACE's mt-0 reflections carry
the mover's real locomotion, so the legs stay correct after any cancel.
The old glide was killed by the R4-V5 stack + #161's apply-pass params
fix. Full evidence: ISSUES Recently-closed #162.
3. ~~**#163 — strip the TEMPORARY diagnostics**~~ **DONE 2026-07-03**
(`[autowalk-gate]` + clock, `[autowalk-feed]`, and the short-lived
`[MOVETO-CANCEL]` probe; the durable ProbeAutoWalk family stays).
4. ~~Combined R2+R3+R4 visual-pass remainder~~ **PASSED 2026-07-03,
user-verified** (jump/ledge momentum, run-in-circles blend, stop
settle, retail-observer view of +Acdream — all four "looks good").
Two observations filed from the pass: **#165** (remote movers get
"swallowed" into walls a bit instead of stopping flush — remote-DR
collision, capture with PROBE_RESOLVE first) and **#166**
(slope-landing glide+bounce polish — the AD-25 + AP-7 + TS-4
composite, post-R6 by user's call). NEXT SESSION ENTERS AT **R5**
(MovementManager facade + StickyManager + ConstraintManager + full
TargetManager — retires TS-39 + AP-79's adapter).
## Session gotchas worth keeping
- **Evidence-first**: two plausible-but-wrong theories (a per-tick
apply_current_movement "pump" — retail's apply callers are EVENT-driven
only; and "ACE chain broadcasts mt-0 locomotion" — it doesn't) were
killed by the raw + wire captures. The `[autowalk-gate]` probe and the
UM raw hex dumps (autonomy byte at offset +14) were the decisive tools.
- **Retail object-model completeness matters**: two of the five fixes were
"retail gives every placed object X and we only gave it to the player"
(a weenie; a cell/InWorld). When a dispatch path behaves differently for
remotes, check what retail's CPhysicsObj carries that our RemoteMotion
lacks BEFORE suspecting the ported logic.
- The launch recipe + graceful-close rules are unchanged (CLAUDE.md); the
client locks builds — ask the user to close it, never kill.
- Register: TS-40 added; TS-33 extended (orientation-diff gap); AP-79
covers both tracker instances; TS-36/AD-26 retired earlier today.
## Key files this session touched
`MotionInterpreter.cs` (StopCompletely sink dispatch, InWorld guards),
`MoveToManager.cs` (MoveToComplete widened to BeginNextNode completion),
`MotionTableDispatchSink.cs` + `InterpretedMotionFunnel.cs`
(StopCompletely sink op), `PhysicsBody.cs` (InWorld), `RemoteWeenie.cs`
(new), `PlayerMovementController.cs` (UseTime slot + per-tick Check +
edge autonomy stores + temp probe), `GameWindow.cs` (P1 gate + store,
RouteServerMoveTo, TickRemoteMoveTo, rm-on-UM, EnterPlayerModeNow bind
order, player tracker feed), tests: `PlayerMoveToCutoverTests`,
`MoveToManagerCompletionSeamTests`, `InWorldLinkGuardTests`,
`RemoteWeenieRunRateTests`, `W6EdgeDrivenMovementTests` (apply-pass form).

View file

@ -0,0 +1,112 @@
# R5 entry handoff — fresh-session entry point (2026-07-03, night)
Successor to `2026-07-03-r4-verify-session-handoff.md` (now fully closed
out — its items 1-4 are all struck through with outcomes). Worktree
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree
CLEAN at `304327b0`. Full suite green: **3,964**.
## State — the R2-R4 arc is DONE
R4 shipped + user-verified; the whole follow-up queue closed **today,
all user-verified live**:
| Item | Outcome |
|---|---|
| #161 landing pose | FIXED `b1cf0102` — retail's apply pass runs with `ModifyInterpretedState=false` (the BN decomp SMEARS the params bitfield store into the mush at raw 305778: `(word & 0x37ff) \| cancelMoveTo<<15 \| disableJump<<17`; ACE MotionInterp.cs:444-449 confirms). Fwd is PRESERVED through a fall; HitGround re-dispatches it → landing link. Plus: HitGround must run BEFORE the DR gravity-bit clear (its verbatim `state&0x400` gate — AP-81); K-fix17 SetCycle blocks deleted; `copy_movement_from` now copies `current_style` (raw 0051e757); ALL apply_current_movement caller polarities fixed (raw arg3 = DisableJumpDuringLink → `(N,0)` = allowJump TRUE). Lesson: [[feedback-bn-decomp-field-names]] (2nd artifact class). |
| #162 glide class | CLOSED, **no adaptation**. User A/B: retail does NOT glide — and post-#161 acdream matches (walk/run-by-distance movetos + clean mid-chain key takeover). Head-interrupt + CancelMoveTo re-audited retail-verbatim; ACE's mt-0 reflections carry the mover's REAL locomotion, so cancels never strand the legs. Evidence: ISSUES Recently-closed #162 + the launch-162 capture analysis. |
| #163 temp diags | STRIPPED `5ebe2be3` ([autowalk-gate]+clock, [autowalk-feed], [MOVETO-CANCEL]). Durable ProbeAutoWalk family kept. |
| R2-R4 visual pass | **PASSED** (jump/ledge momentum, run-in-circles, stop settle, retail-observer view). Filed #165 (remote wall-swallow) + #166 (slope-landing sled — the AD-25+AP-7+TS-4 composite, post-R6 per user). |
## NEXT: R5 — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
**Retires: TS-39** (MoveToManager `StickTo`/`Unstick` are unbound no-op
seams — sticky movetos complete-and-stop instead of sticking; retail
hands off to `PositionManager::StickTo(tlid, radius, height)`, and every
`PerformMovement` head calls `unstick_from_object`
`PositionManager::UnStick`) **and AP-79** (the P4 TargetTracker minimal
adapter — GameWindow feeds `HandleUpdateTarget` from the entity table;
retail's TargetManager is a full voyeur-subscription system with
per-target quantums).
### Retail function inventory (grep'd, addresses verified)
- **MovementManager** (facade, mostly thin): 00524000 MakeMoveToManager ·
00524020 SetWeenieObject · 005240d0 PerformMovement · 00524170
move_to_interpreted_state · 005241b0 CancelMoveTo · 005241c0
EnterDefaultState · 00524260 IsMovingTo · 00524280 motions_pending ·
005242d0 MotionDone · 005242f0 UseTime · 00524300 HitGround · 00524320
LeaveGround · 00524340/00524350 HandleEnter/ExitWorld · 00524360
ReportExhaustion · 00524440 unpack_movement · 00524790
HandleUpdateTarget (routes TargetInfo → MoveToManager). Much of this
facade's BEHAVIOR is already ported piecemeal (funnel, RouteServerMoveTo,
TickRemoteMoveTo, the UseTime/CheckForCompletedMotions slots) — R5's job
is the STRUCTURE: one owner object per entity replacing the GameWindow
wiring sprawl, without changing dispatch behavior pinned by the
183-case + funnel + moveto suites.
- **StickyManager**: 00555400 UnStick · 00555430 adjust_offset · 00555610
UseTime · 005556e0 SetPhysicsObject · 00555710 StickTo · 00555780
HandleUpdateTarget.
- **ConstraintManager**: 00556090 SetPhysicsObject · 005560c0 UnConstrain ·
005560d0 IsFullyConstrained · 00556180 adjust_offset · 00556240
ConstrainTo. (`jump_is_allowed` already calls an `IsFullyConstrained`
stub — find it before re-porting.)
- **PositionManager** (owns the two): CPhysicsObj::MakePositionManager
00510210 (also sets transient bit 0x80 + update_time). `stick_to_object`
/ `unstick_from_object` on CPhysicsObj are the entry seams (today
Action seams on MotionInterpreter, wired per-entity in GameWindow).
- **TargetManager**: 0051a370 ctor · 0051a4a0 SetTargetQuantum · 0051a4f0
SendVoyeurUpdate · 0051a5e0 GetInterpolatedPosition · 0051a650
CheckAndUpdateVoyeur · 0051a6f0 NotifyVoyeurOfEvent · 0051a7e0
ClearTarget · 0051a830 AddVoyeur · 0051a930 ReceiveUpdate · 0051aa90
HandleTargetting · 0051ac30 SetTarget · 0051ad90 RemoveVoyeur.
AP-79's seams (`_setTarget`/`_clearTarget`/quantum get/set +
GameWindow's tracker blocks for remotes AND the `_playerMoveToTarget*`
player twin) are the integration points to replace.
### Already ported — do NOT re-port
MoveToManager (R4, full incl. the queue model), MotionInterpreter (R3
full; #161 corrected the apply-pass params), MotionTableManager +
GetObjectSequence (R2), CSequence (R1), RemoteWeenie + PhysicsBody.InWorld
(R4-V5), DefaultSink binding order (bind BEFORE SetPosition — the
`LoginQueue_DrainsToEmpty_UnderProductionFeed` invariant).
### R5-adjacent verification items (found reading unpack_movement this session)
1. **Head stance-change dispatch**: raw @00524502-0052452c — before the
type switch, for ALL movement types: `if (InqStyle() !=
command_ids[stanceIdx]) CMotionInterp::DoMotion(style, defaults)` (BN
mislabels InqStyle as `CBaseFilter::GetPinVersion`). Verify our UM
path does this for types 6-9 (mt-0 handles style inside the funnel).
2. **mt-0 header flags**: 0x100 → read sticky guid → `stick_to_object`
(raw @00524589); 0x200 → `standing_longjump` store (raw @0052458e).
`UpdateMotion.cs` PARSES both (cited §2f) — verify they're APPLIED
(sticky needs TS-39's machinery = this phase; longjump flag should
reach `MotionInterpreter.StandingLongJump`).
3. **#164** (filed): action-replay dispatches drop the per-action
Autonomous bit (raw 305982 sets params bit 0x1000 → AddAction). Fix
while touching the action path.
## The load-bearing lessons (compressed)
- **The queue model** (unchanged from R4): every `pending_motions`
enqueue needs a manager completion partner; one orphan = permanent
MotionsPending wedge.
- **BN smeared bitfield stores**: a fresh local struct + later "mush"
expression = decode the mush BEFORE porting "ctor defaults". ACE's C#
port of the same function is the fastest cross-check. Cost of missing
it: #161 + the wrong W6 entry-cache theory + a wrong pinned test.
- **Tests can pin bug values**: `AirborneBody_NoCycleDispatches_...`
asserted the CLOBBERED fwd (0x40000015) with a comment admitting it was
untested. When flipping behavior, audit what the old assertions PINNED.
## Session gotchas
- Launch recipe unchanged (CLAUDE.md). Client LOCKS builds — ask the user
to close it, never kill. Graceful close clears the ACE session in ~5 s.
- PowerShell `Tee-Object` writes UTF-16 → grep the background task's
plain-text output file instead of the tee'd log.
- ACDREAM_DUMP_MOTION=1 + ACDREAM_REMOTE_VEL_DIAG=1 give the full UM/seq
evidence chain ([UM_RAW] hex incl. autonomy byte, [FWD_WIRE],
[SEQSTATE], [MOTIONDONE]); #165's first step is ACDREAM_PROBE_RESOLVE
at a wall.

View file

@ -0,0 +1,272 @@
# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace
Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Read-only recon. All line numbers verified against files as of this session (2026-07-03).
---
## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines)
### Ctor-required seam delegates (lines 78-138, ctor 184-222)
All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null):
- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely`
- `_getPosition` (L83) — retail `physics_obj->m_position`
- `_getHeading` (L87) — retail `CPhysicsObj::get_heading`
- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168)
- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims
- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate
- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet)
- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune
- `_getSelfId` (L120) — self-target detection
- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)**
- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)**
- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)**
- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub)
### AP-79 target seams (setTarget/clearTarget)
- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0.
- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`.
- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running.
- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`.
### TS-39 sticky seams (StickTo/Unstick)
- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object`**PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op.
- `public Action<uint,float,float>? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op.
- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.)
### setHeading seam
- `_setHeading: Action<float,bool>` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire.
### Public surface (entry points + drivers)
- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below)
- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613)
- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c).
- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above)
- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d).
- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only)
- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516)
- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action.
---
## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines)
### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308)
Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239):
- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega`
- `rm.Motion.DefaultSink = rm.Sink` (L4256)
- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257)
- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258)
- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush
- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before)
- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain
- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes.
### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+)
Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988):
- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly
- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy
- (c) `isInterpolating: () => false` — player has no InterpolationManager
- `playerMoveTo = new MoveToManager(...)` (L12990-13013)
- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor)
- `_playerController.MoveTo = playerMoveTo` (L13031)
- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side
- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans.
- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`.
- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e).
### 2c. AP-79 tracker block + player pre-Update feed
- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504).
- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.**
### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites
- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00)
- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`)
- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches)
- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain)
- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate)
- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame)
- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`)
### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also)
`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites:
- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566).
- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829).
### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file)
Manager-related fields:
- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414)
- `MotionTableDispatchSink? Sink` (L419)
- `MoveToManager? MoveTo` (L436) — R4-V4
- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446)
- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup.
---
## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines)
- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff.
- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto).
### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981)
```csharp
public void ApplyInterpretedMovement(
uint currentStyle, IInterpretedMotionSink? sink,
bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
// Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449):
// Speed is re-set per axis below, everything else rides the pass.
var p = new MovementParameters
{
SetHoldKey = false,
ModifyInterpretedState = false,
CancelMoveTo = cancelMoveTo,
DisableJumpDuringLink = !allowJump,
};
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
p.Speed = 1.0f;
DoInterpretedMotion(currentStyle, p, sink);
if (!contact_allows_move(InterpretedState.ForwardCommand))
{
p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000
DoInterpretedMotion(MotionCommand.Falling, p, sink);
}
else if (StandingLongJump)
{
p.Speed = 1.0f;
DoInterpretedMotion(MotionCommand.Ready, p, sink);
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink);
if (InterpretedState.SideStepCommand == 0)
{
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.SideStepSpeed;
DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink);
}
}
if (InterpretedState.TurnCommand != 0)
{
p.Speed = InterpretedState.TurnSpeed;
DoInterpretedMotion(InterpretedState.TurnCommand, p, sink);
return; // retail early return — no idle-stop this call
}
// Tail: unconditional StopInterpretedMotion(TurnRight, params).
StopInterpretedMotion(MotionCommand.TurnRight, p, sink);
}
```
**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`.
This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state.
---
## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines)
### mt-0 header flag parsing
- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs.
- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code:
```csharp
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
```
Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order).
- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file.
### Downstream consumers (grep of whole src tree)
- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`.
- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself.
- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet.
---
## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question
- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468).
- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today.
---
## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines)
- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true).
- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`):
- `Contact = 0x1` (bit 0) — touching any surface
- `OnWalkable = 0x2` (bit 1) — standing on walkable surface
- `Sliding = 0x4` (bit 2) — carry sliding normal into next transition
- `Active = 0x80` (bit 7) — needs per-frame update
- Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`.
- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager.
---
## 7. Test files (locations, not full case enumeration)
**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`:
- `MoveToManagerTestHarness.cs` — shared harness/fixture
- `MoveToManagerLifecycleTests.cs`
- `MoveToManagerNodePlanTests.cs`
- `MoveToManagerBeginMoveForwardTests.cs`
- `MoveToManagerAuxTurnTests.cs`
- `MoveToManagerArrivalAndProgressTests.cs`
- `MoveToManagerTurnToHeadingTests.cs`
- `MoveToManagerHandleUpdateTargetTests.cs`
- `MoveToManagerStickyAndCancelTests.cs`
- `MoveToManagerUseTimeGateTests.cs`
- `MoveToManagerEndToEndTableDriveTests.cs`
- `MoveToManagerCompletionSeamTests.cs`
- `MovementTypeWideningTests.cs`
- `MoveToMathCylinderDistanceTests.cs`
- `MotionTableDispatchSinkTests.cs`
- `InterpretedMotionStateActionFifoTests.cs`
**Funnel / 183-case conformance suite**:
- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`).
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs`
- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent)
**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`:
- `MotionInterpreterTests.cs`
- `MotionInterpreterDoMotionFamilyTests.cs`
- `MotionInterpreterGroundLifecycleTests.cs`
- `MotionInterpreterJumpFamilyTests.cs`
- `MotionInterpreterPendingMotionsTests.cs`
- `ServerControlledLocomotionTests.cs`
- `RemoteWeenieRunRateTests.cs`
- `InWorldLinkGuardTests.cs`
- `MotionNormalizationTests.cs`
- `MotionVelocityPipelineTests.cs`
- `AnimationSequencerCutoverTraceTests.cs`
- `WeenieErrorCodeTableTests.cs`
**Player moveto cutover**:
- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs`
---
## Summary answers to the task's specific questions
**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid``ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere.
**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh.
**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player).
**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md`

View file

@ -0,0 +1,332 @@
# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/`
All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines,
StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines).
---
## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) |
| `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) |
| `PhysicsObj` | `PhysicsObj` | null (set in ctor) |
| `WeenieObj` | `WeenieObject` | null (set in ctor) |
No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself.
### Constructors
- `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape).
- `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children.
### Methods (signature + mechanical summary)
- `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`.
- `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`.
- `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below.
- `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op.
- `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all).
- `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved.
- `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager.
- `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`.
- `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`.
- `IsMovingTo()` (L97-102) — `MoveToManager == null``false`, else `MoveToManager.is_moving_to()`.
- `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`).
- `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`.
- `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`.
- `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub:
- `PhysicsObj.set_active(true)` unconditionally first.
- `switch (mvs.Type)`:
- `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`.
- `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`.
- `default``return WeenieError.GeneralMovementFailure`.
- This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both.
- `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead).
- `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`.
- `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file).
- `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state).
- `motions_pending()` (L195-200) — `MotionInterpreter == null``false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194).
- `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`.
- `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences).
### Divergences / ACE adaptations
1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch."
2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead.
3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side.
4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses).
5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator.
---
## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `InterpolationManager` | `InterpolationManager` | null |
| `StickyManager` | `StickyManager` | null |
| `ConstraintManager` | `ConstraintManager` | null |
| `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) |
All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor.
### Constructors
- `PositionManager()` — parameterless/default.
- `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`.
### Methods
- **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**:
1. `InterpolationManager.adjust_offset(frame, quantum)`
2. `StickyManager.adjust_offset(frame, quantum)`
3. `ConstraintManager.adjust_offset(frame, quantum)`
All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution.
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`.
- `static Create(PhysicsObj physicsObj)` (L38-41) — factory.
- `GetStickyObjectID()` (L43-47) — `StickyManager == null``0`, else `StickyManager.TargetID`.
- `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates).
- `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`.
- `IsFullyConstrained()` (L63-69) — `ConstraintManager == null``false`, else `ConstraintManager.IsFullyConstrained()`.
- `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`.
- `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`.
- `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call).
- `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`.
- `StopInterpolating()` (L101-105) — null-guarded forward.
- `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`.
- `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`).
- `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`).
### Divergences / ACE adaptations
- None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence.
- `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates.
---
## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `TargetID` | `uint` | 0 |
| `TargetRadius` | `float` | 0 |
| `TargetPosition` | `Position` | null |
| `PhysicsObj` | `PhysicsObj` | null |
| `Initialized` | `bool` | false |
| `StickyTimeoutTime` | `double` | 0 |
| `StickyRadius` | `const float` | **0.3f** (L20) |
| `StickyTime` | `const float` | **1.0f** (L22) |
### Constructors
- `StickyManager()` — default.
- `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`.
### Methods
- `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`.
- `static Create(PhysicsObj obj)` (L44-47) — factory.
- `HandleExitWorld()` (L49-52) — calls `ClearTarget()`.
- `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK``Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0``ClearTarget()` (i.e., any non-OK status for our current target clears it).
- `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter.
- **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then:
- `TargetID = objectID`
- `Initialized = false`
- `TargetRadius = targetRadius`
- `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`)
- `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.**
- `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime``ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.)
- **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.**
1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready).
2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range).
3. **Offset vector (world → local, flattened to XY):**
```
offset.Origin = PhysicsObj.Position.GetOffset(targetPosition);
offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin);
offset.Origin.Z = 0.0f;
```
i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally.
4. **Distance computation:**
```
var radius = PhysicsObj.GetRadius();
var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius;
```
`CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap.
5. **Normalize direction** (with small-vector guard):
```
if (Vec.NormalizeCheckSmall(ref offset.Origin))
offset.Origin = Vector3.Zero;
```
`NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range).
6. **Speed selection:**
```
var speed = 0.0f;
var minterp = PhysicsObj.get_minterp();
if (minterp != null)
speed = minterp.get_max_speed() * 5.0f;
if (speed < PhysicsGlobals.EPSILON)
speed = 15.0f;
```
Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0.
7. **Delta-clamp to distance:**
```
var delta = speed * (float)quantum;
if (delta >= Math.Abs(dist))
delta = dist;
offset.Origin *= delta;
```
Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off).
8. **Heading alignment:**
```
var curHeading = PhysicsObj.Position.Frame.get_heading();
var targetHeading = PhysicsObj.Position.heading(targetPosition);
var heading = targetHeading - curHeading;
if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f;
if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f;
offset.set_heading(heading);
```
Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller).
- Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131).
### Divergences / ACE adaptations
1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height).
2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable.
3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line.
---
## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `PhysicsObj` | `PhysicsObj` | null |
| `IsConstrained` | `bool` | false |
| `ConstraintPosOffset` | `float` | 0 |
| `ConstraintPos` | `Position` | null |
| `ConstraintDistanceStart` | `float` | 0 |
| `ConstraintDistanceMax` | `float` | 0 |
### Constructors
- `ConstraintManager()` — default.
- `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`.
### Methods
- `static Create(PhysicsObj obj)` (L22-25) — factory.
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero).
- `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;`**"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max.
- `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway).
- `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call).
- `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog).
- **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.**
1. Guard: `PhysicsObj == null || !IsConstrained` → return.
2. **Contact-gated branch:**
```
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
{
if (ConstraintPosOffset < ConstraintDistanceMax)
{
if (ConstraintPosOffset > ConstraintDistanceStart)
offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart);
}
else
offset.Origin = Vector3.Zero;
}
```
The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact).
When in contact:
- If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned).
- Else if offset is **< ConstraintDistanceMax**:
- If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**:
```
(ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart)
```
This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance.
- If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged.
3. **State update (runs unconditionally, both when in contact and when airborne):**
```
ConstraintPosOffset = offset.Origin.Length();
```
**This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos."
### Divergences / ACE adaptations
- No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file.
---
## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`)
Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four.
### Fields
| Field | Type | Initial value |
|---|---|---|
| `PhysicsObj` | `PhysicsObj` | null |
| `TargetInfo` | `TargetInfo` | null |
| `VoyeurTable` | `Dictionary<uint, TargettedVoyeurInfo>` | null (lazily created in `AddVoyeur`) |
| `LastUpdateTime` | `double` | 0 |
### Constructors
- `TargetManager()` — default.
- `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes).
### Methods
- `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it).
- `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`).
- **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards:
1. `PhysicsObj == null` → return.
2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles).
3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64).
4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems.
5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each.
6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window.
- `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius``SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast.
- `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum`**dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta.
- `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point).
- `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling.
- **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all).
- `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty).
- `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`).
- **Heading computation:**
```
TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition);
if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading))
TargetInfo.InterpolatedHeading = Vector3.UnitZ;
```
Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer).
- `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget``StickyManager.HandleUpdateTarget`, wiring back to file #3 above).
- If `update.Status == TargetStatus.ExitWorld``ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did).
- `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates).
- `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`.
- `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null``false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal).
### Divergences / ACE adaptations
1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty.
2. **The voyeur pattern is inherently server-authoritative plumbing**`SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client).
3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere.
4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure.
---
## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work)
- **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file.
- **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`.
- **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it).
- **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section:
1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step.
2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta).
3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.

View file

@ -0,0 +1,679 @@
# ConstraintManager — retail decomp extract
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named).
Struct source: `docs/research/named-retail/acclient.h`.
**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`.
The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition
line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`)
and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`.
---
## struct ConstraintManager (acclient.h, comment `/* 3467 */`)
```c
/* 3467 */
struct __cppobj ConstraintManager
{
CPhysicsObj *physics_obj;
int is_constrained;
float constraint_pos_offset;
Position constraint_pos;
float constraint_distance_start;
float constraint_distance_max;
};
```
## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object
```c
/* 3468 */
struct __cppobj PositionManager
{
InterpolationManager *interpolation_manager;
StickyManager *sticky_manager;
ConstraintManager *constraint_manager;
CPhysicsObj *physics_obj;
};
```
Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`,
`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation
writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the
`PositionManager::Destroy` teardown order (`interpolation_manager``sticky_manager`
`constraint_manager`).
`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset
writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait,
per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc`
(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is
`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a
`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field —
see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and
`constraint_distance_start`, consistent with the struct's declared member order.
---
## ConstraintManager::SetPhysicsObject — `0x00556090`
```c
00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)
00556090 {
00556096 if (this->physics_obj == 0)
00556096 {
005560ad this->physics_obj = arg2;
005560af return;
00556096 }
00556096
00556098 this->physics_obj = 0;
0055609a this->is_constrained = 0;
0055609d this->constraint_pos_offset = 0f;
005560a4 this->physics_obj = arg2;
00556090 }
```
## ConstraintManager::UnConstrain — `0x005560c0`
```c
005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)
005560c0 {
005560c0 this->is_constrained = 0;
005560c0 }
```
## ConstraintManager::IsFullyConstrained — `0x005560d0`
```c
005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)
005560d0 {
005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset);
005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
005560dc (x87_r6_1 - x87_r7);
005560de int32_t eax;
005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */;
005560e0
005560e3 if (p)
005560ed return 0;
005560ed
005560ea return 1;
005560d0 }
```
NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's
attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as
bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset`
and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`.
The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset`
OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2
(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent
pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >=
0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms:
the object counts as "fully constrained" while it is still within 90% of the max leash
distance; once it has drifted past 90% of that distance it is no longer "fully" constrained
(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while
straining at the very end of a constraint leash).
## ConstraintManager::~ConstraintManager — `0x005560f0`
```c
005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)
005560f0 {
005560f2 this->is_constrained = 0;
005560f5 this->constraint_pos_offset = 0f;
005560f8 this->physics_obj = 0;
005560fa this->constraint_pos.vtable = 0x79285c;
005560f0 }
```
NOTE: `this->constraint_pos.vtable = 0x79285c``constraint_pos` is a `Position` field
(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded
`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the
`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap";
just the compiler zeroing/resetting the embedded Frame's identity field during teardown.
## ConstraintManager::Create — `0x00556110` (factory)
```c
00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)
00556110 {
00556114 void* result = operator new(0x5c);
00556114
00556122 if (result == 0)
00556177 return 0;
00556177
00556124 *(uint32_t*)result = 0;
00556126 *(uint32_t*)((char*)result + 4) = 0;
00556129 *(uint32_t*)((char*)result + 8) = 0;
0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910;
00556136 *(uint32_t*)((char*)result + 0x10) = 0;
00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
0055613f *(uint32_t*)((char*)result + 0x18) = 0;
00556142 *(uint32_t*)((char*)result + 0x1c) = 0;
00556145 *(uint32_t*)((char*)result + 0x20) = 0;
00556148 *(uint32_t*)((char*)result + 0x48) = 0;
0055614b *(uint32_t*)((char*)result + 0x4c) = 0;
0055614e *(uint32_t*)((char*)result + 0x50) = 0;
00556151 Frame::cache(((char*)result + 0x14));
00556156 *(uint32_t*)((char*)result + 0x54) = 0;
00556159 *(uint32_t*)((char*)result + 0x58) = 0;
00556159
0055615e if (*(uint32_t*)result != 0)
0055615e {
00556160 *(uint32_t*)((char*)result + 4) = 0;
00556163 *(uint32_t*)((char*)result + 8) = 0;
00556166 *(uint32_t*)result = 0;
0055615e }
0055615e
0055616c *(uint32_t*)result = arg1;
00556172 return result;
00556110 }
```
NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping
against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`,
`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose
own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc`
and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0`
i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`,
`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the
declared struct fields in the header excerpt we have — likely padding/alignment or a
field the header comment block truncated; not load-bearing for the port (all consumed
fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`,
`constraint_distance_start`, `constraint_distance_max` — are accounted for).
The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads
as dead/defensive code from an inlined check that can't actually trigger here (the fields
were all just zeroed above) — flagging as NOTE, not altering.
## ConstraintManager::adjust_offset — `0x00556180`
```c
00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)
00556180 {
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
00556186
0055618a if (physics_obj != 0)
0055618a {
00556190 int32_t is_constrained = this->is_constrained;
00556190
00556195 if (is_constrained != 0)
00556195 {
005561a7 if ((physics_obj->transient_state & 1) != 0)
005561a7 {
005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset);
005561ac long double temp1_1 = ((long double)this->constraint_distance_max);
005561ac (x87_r7_1 - temp1_1);
005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
005561af
005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
005561b4 {
005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset);
005561ea long double temp2_1 = ((long double)this->constraint_distance_start);
005561ea (x87_r7_2 - temp2_1);
005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */;
005561ef
005561f2 if (p_1)
005561f2 {
005561f7 int32_t is_constrained_1 = is_constrained;
00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
005561f2 }
005561b4 }
005561b4 else
005561b4 {
005561c2 arg2->m_fOrigin.x = 0;
005561c2 arg2->m_fOrigin.y = 0f;
005561c2 arg2->m_fOrigin.z = 0f;
005561b4 }
005561a7 }
005561a7
0055620e arg2->m_fOrigin;
00556211 arg2->m_fOrigin;
00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
00556195 }
0055618a }
00556180 }
```
NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as
a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at
`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the
`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's
actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4`
(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state`
again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port
the reused `physics_obj` int32 as if it becomes a different physics object; it's the same
pointer, just overwritten as dead-value scratch space by the decompiler's register
allocator view.
`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as
`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on
encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare
(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body
computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) /
(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via
`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached)
`constraint_distance_start`, scale the frame's offset delta by how far through the
start→max leash band the object currently sits** (a ramp/taper multiplier — presumably
smoothly reducing how much of the requested frame delta gets applied as the leash
tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal"
(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the
band; once past `constraint_distance_start` typical port intent should skip the scale
(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which
zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear.
Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check
if the port's leash-taper feel diverges from retail), the function's shape is:
1. No-op if no `physics_obj` or not `is_constrained`.
2. If `transient_state & 1` (some "active"/"in contact" style flag):
- If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta
`arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`,
gated by a comparison of `pos_offset` vs `constraint_distance_start`.
- Else: zero the incoming frame delta entirely (fully clamp movement).
3. Unconditionally (after the above), accumulate: `constraint_pos_offset +=
arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read
twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read
from the decompiler, or hints there's a per-component variant BN collapsed; only the
final `.x`-add survived as an observable store).
## ConstraintManager::ConstrainTo — `0x00556240`
```c
00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)
00556240 {
00556248 this->is_constrained = 1;
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271 this->constraint_distance_start = arg3;
00556274 this->constraint_distance_max = arg4;
0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
00556240 }
```
Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame),
sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and
initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the
physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) —
i.e. the leash starts already "extended" to wherever the object presently is relative to
the constraint anchor, not to zero.
---
## PositionManager-level seams (the actual public API — ConstraintManager is
## lazily-created and private underneath)
`ConstraintManager` is never touched directly from outside `PositionManager`.
`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it.
```c
00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
00555190 {
00555191 int32_t ebx = arg3;
0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager;
005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4];
005551a2
005551a6 if (interpolation_manager != 0)
005551a6 {
005551a8 int32_t var_14_1 = edi;
005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
005551a6 }
005551a6
005551b0 class StickyManager* sticky_manager = this->sticky_manager;
005551b0
005551b5 if (sticky_manager != 0)
005551b5 {
005551b7 int32_t var_14_2 = edi;
005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx);
005551b5 }
005551b5
005551bf class ConstraintManager* constraint_manager = this->constraint_manager;
005551bf
005551c4 if (constraint_manager != 0)
005551c4 {
005551c6 int32_t var_14_3 = edi;
005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
005551c4 }
00555190 }
```
Chains ALL THREE sub-managers' `adjust_offset` in a fixed order:
`InterpolationManager``StickyManager``ConstraintManager`, each optional (only
called if that sub-manager exists). This is the per-frame(?) offset-adjustment
dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say
in shaping a `Frame` delta before it's applied.
```c
00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
00555280 {
00555288 if (this->constraint_manager == 0)
00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj);
00555296
00555299 class ConstraintManager* constraint_manager = this->constraint_manager;
00555299
0055529f if (constraint_manager == 0)
005552a6 return;
005552a6
005552a1 /* tailcall */
005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
00555280 }
005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this)
005552b0 {
005552b0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552b0
005552b5 if (constraint_manager == 0)
005552bc return;
005552bc
005552b7 /* tailcall */
005552b7 return ConstraintManager::UnConstrain(constraint_manager);
005552b0 }
005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
005552c0 {
005552c0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552c0
005552c5 if (constraint_manager == 0)
005552ce return 0;
005552ce
005552c7 /* tailcall */
005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager);
005552c0 }
```
`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s
`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for
copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers):
```c
005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
005552d0 {
005552d3 void* result = operator new(0x10);
...
0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
0055531d
00555322 if (ecx_2 != 0)
00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1);
00555325
0055532e return result;
005552d0 }
```
`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three
sub-managers, `ConstraintManager` last:
```c
00555340 void __fastcall PositionManager::Destroy(class PositionManager* this)
00555340 {
...
00555377 class ConstraintManager* constraint_manager = this->constraint_manager;
0055537c this->sticky_manager = nullptr;
0055537c
00555383 if (constraint_manager != 0)
00555383 {
00555387 ConstraintManager::~ConstraintManager(constraint_manager);
0055538d operator delete(constraint_manager);
00555383 }
00555383
00555396 this->constraint_manager = nullptr;
00555340 }
```
---
## CPhysicsObj-level seams (public API callers actually use)
```c
0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
0050ec10 {
0050ec16 if (this == CPhysicsObj::player_object)
0050ec16 {
0050ec18 this->m_position;
0050ec2d return;
0050ec16 }
0050ec16
0050ec35 this->m_position;
0050ec10 }
0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
0050ebc0 {
0050ebc6 if (this == CPhysicsObj::player_object)
0050ebc6 {
0050ebc8 this->m_position;
0050ebdd return;
0050ebc6 }
0050ebc6
0050ebe5 this->m_position;
0050ebc0 }
```
NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void`
signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're
called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by
`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that
Binary Ninja failed to attach to the declared return type). Body-wise all we get is
`this->m_position;` as a bare expression on both the player-branch and fallthrough
paths — BN elided the actual field read/constant selection (this is likely
`this->m_position.something` or a per-type constant lookup that got collapsed to a
dead-looking statement). **This function needs a live cdb read of `st0` after the call,
or a Ghidra re-decompile with a corrected float-return signature, to recover the actual
values.** Given the call-site pattern (constraining the player and other movers to a
"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`),
the two functions almost certainly return small fixed-radius constants (a "start easing"
radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case
(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal
values — flag as an open research item before porting numeric constants.
```c
00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
00510520 {
00510523 CPhysicsObj::MakePositionManager(this);
00510528 class PositionManager* position_manager = this->position_manager;
00510528
00510531 if (position_manager == 0)
00510538 return;
00510538
00510533 /* tailcall */
00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
00510520 }
```
`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists
(`MakePositionManager`) then forwards. This is the entry point external code calls.
```c
0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
0050ec60 {
0050ec60 class PositionManager* position_manager = this->position_manager;
0050ec60
0050ec68 if (position_manager == 0)
0050ec71 return 0;
0050ec71
0050ec6a /* tailcall */
0050ec6a return PositionManager::IsFullyConstrained(position_manager);
0050ec60 }
```
(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied
`0x0050f730`.)
There is no separate `CPhysicsObj::UnConstrain` — callers go straight to
`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only
`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper.
---
## CALLERS — when does retail actually constrain an object?
### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site
All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one
function, at three different branches of its position-update-reconciliation logic:
**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):**
```c
0045414d if (arg2 != this->player)
0045414d {
00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
00454254 {
00454258 int32_t ecx_26;
00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045425d int32_t var_68_14 = ecx_26;
00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
00454268 int32_t var_6c_9 = ecx_28;
00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
00454254 }
00454254
00454254 return;
0045414d }
```
For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained
**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii
from `GetStartConstraintDistance`/`GetMaxConstraintDistance`.
**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):**
```c
0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
0045415f {
00454168 SmartBox::TeleportPlayer(this, &var_48);
0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
0045418f class CPhysicsObj* player_2 = this->player;
0045419c int32_t var_54 = 0; // zero vector
004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1);
004541c0 return;
0045415f }
```
On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then
the player is constrained **to the newly-received server position** (`&var_48`, the
decoded incoming `Position`), and velocity is zeroed.
**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):**
```c
004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
0045420a {
... CPhysicsObj::InterpolateTo(arg2, &var_48, ...);
```
Every OTHER received server position update for the local player (not a teleport-flagged
event) ALSO constrains the player to the received position (`&var_48`), and then —
depending on `UsePositionFromServer`/autonomy settings — may additionally kick off
`InterpolateTo`. So the leash gets re-anchored on essentially every server position
correction, whether or not interpolation is used to visually smooth toward it.
**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or
server-received) with a start/max leash-band pair **every time `SmartBox` processes an
inbound position update for that object** — this is the "rubber-band" leash mechanism
that keeps the client's locally-simulated position from drifting too far from the
server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on
every inbound position packet, not set once.
### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site
```c
00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
00514ed0 {
00514ed3 class MovementManager* movement_manager = this->movement_manager;
00514edb if (movement_manager != 0)
00514edb MovementManager::CancelMoveTo(movement_manager, edx);
00514ee4 class PositionManager* position_manager = this->position_manager;
00514eec if (position_manager != 0)
00514eee PositionManager::UnStick(position_manager);
00514ef3 class PositionManager* position_manager_1 = this->position_manager;
00514efb if (position_manager_1 != 0)
00514efd PositionManager::StopInterpolating(position_manager_1);
00514f02 class PositionManager* position_manager_2 = this->position_manager;
00514f0a if (position_manager_2 != 0)
00514f0c PositionManager::UnConstrain(position_manager_2);
00514f11 class TargetManager* target_manager = this->target_manager;
00514f19 if (target_manager != 0)
00514f19 {
00514f1b TargetManager::ClearTarget(target_manager);
00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
00514f19 }
00514f31 CPhysicsObj::report_collision_end(this, 1);
00514ed0 }
```
The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just
got relocated in a way that invalidates all continuity state" cleanup: it cancels any
active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears
target tracking, and ends collision reporting. So the leash is torn down whenever an
object teleports (any teleport, not just the player's) — makes sense, since a teleport by
definition means "the position just legitimately jumped," so the anti-drift leash from
the PREVIOUS anchor must be dropped rather than fight the teleport.
### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site
```c
005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
005282b0 {
005282b8 if (this->physics_obj != 0)
005282b8 {
...
005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
00528305 return 0x47;
00528305
00528308 class LListData* head_ = this->pending_motions.head_;
...
```
`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code
`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge
state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the
mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is
still within 90% of its max leash distance from the constraint anchor, it counts as
"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the
client's simulated position is being actively rubber-banded back toward a server-received
position inside the tight leash band.** Only once you've drifted past 90% of the leash
(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate
open.
---
## CPhysicsObj-level "constrain" seam grep (exhaustive)
Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the
corpus — every call site, no filtering:
```
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A]
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B]
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C]
276520 CPhysicsObj::IsFullyConstrained (definition)
276529 -> PositionManager::IsFullyConstrained (tailcall)
278353 CPhysicsObj::ConstrainTo (definition)
278363 -> PositionManager::ConstrainTo (tailcall)
283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook]
305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed]
352186 PositionManager::ConstrainTo (definition)
352198 -> ConstraintManager::ConstrainTo (tailcall)
352203 PositionManager::UnConstrain (definition)
352212 -> ConstraintManager::UnConstrain (tailcall)
352217 PositionManager::IsFullyConstrained (definition)
352226 -> ConstraintManager::IsFullyConstrained (tailcall)
353405 ConstraintManager::UnConstrain (definition)
353413 ConstraintManager::IsFullyConstrained (definition)
353528 ConstraintManager::ConstrainTo (definition)
```
No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain
mechanism is used EXCLUSIVELY by:
- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates
(3 branches: self-anchor for remote movers, server-anchor for player teleport, server-
anchor for player non-teleport updates), and
- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and
- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate.
This is a narrow, special-purpose "server position rubber-band leash," NOT a general
physics constraint/joint system.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,338 @@
# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the
sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`;
current acdream integration seams in `r5-acdream-seams.md`. This doc is the
**pseudocode + integration + sub-slice plan** (mandatory workflow step 3).
Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4
TargetTracker adapter). ConstraintManager is ported for structural completeness
of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35
stays open**.
---
## 0. The retail structure vs acdream's reality
**Retail** (`CPhysicsObj` owns three managers, all lazily created):
```
CPhysicsObj
├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager)
├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint)
└── target_manager : TargetManager (voyeur subscription system)
```
Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order
(decomp §positionmanager-sticky callers):
1. `DetectionManager::CheckDetection`
2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime)
3. `MovementManager::UseTime``MoveToManager::UseTime` only
4. `CPartArray::HandleMovement`
5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime
And `UpdatePositionInternal` (0x00512c30) composes the frame delta:
`Frame::cache(&d)``CPartArray::Update(d)` (animation) →
`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into
the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition
(collision) → commit. **Sticky/constraint steering composes with animation motion
in the same per-tick delta frame, before collision resolves it.**
**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is:
| Retail | acdream (remote) | acdream (player) |
|---|---|---|
| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields |
| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` |
| `position_manager` | **absent** | **absent** |
| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block |
| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` |
R5 adds the two missing managers per-entity. The MovementManager facade (collapsing
`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement
targets don't require it, so it lands last and only if the arc has budget.
---
## 1. Struct/field map (acclient.h → C#)
```
MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj
PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj
StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position,
physics_obj, initialized:int, sticky_timeout_time:double
ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float,
constraint_pos:Position, constraint_distance_start:float,
constraint_distance_max:float
TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash<TargettedVoyeurInfo>,
last_update_time:double
TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double,
target_position:Position, interpolated_position:Position,
interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus,
last_update_time:double
TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float,
last_sent_position:Position
```
acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in
`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add
`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`,
`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads
`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe.
---
## 2. The math — decoded (ACE is the oracle for the x87 mush)
All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the
BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush
structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the
retail address.**
### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91)
```
// Guard: no-op unless PhysicsObj && target_id != 0 && initialized
target = GetObjectA(target_id) // live resolve
targetPos = target != null ? target.Position : cached target_position // fallback to last-known
offset = self.Position.GetOffset(targetPos) // world vector self→target
offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame
offset.Z = 0 // horizontal-only
radius = self.GetRadius()
dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos)
- 0.3f // StickyRadius const (ACE StickyRadius=0.3f)
if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter
speed = 0
minterp = self.get_minterp()
if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up)
if speed < EPSILON: speed = 15.0f // fallback
delta = speed * quantum
if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE)
offset.Origin *= delta
curHeading = self.Position.Frame.get_heading()
targetHeading = self.Position.heading(targetPos)
h = targetHeading - curHeading
if abs(h) < EPSILON: h = 0
if h < -EPSILON: h += 360
offset.set_heading(h) // per-tick RELATIVE turn toward target
```
Net: a speed-clamped horizontal steer + bounded turn toward the stuck target,
written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f`
are the two named constants.
### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62)
```
// Guard: no-op unless PhysicsObj && is_constrained
if (self.TransientState & Contact): // ONLY clamps while grounded
if constraint_pos_offset < constraint_distance_max:
if constraint_pos_offset > constraint_distance_start:
offset.Origin *= (constraint_distance_max - constraint_pos_offset)
/ (constraint_distance_max - constraint_distance_start) // linear brake taper
else:
offset.Origin = Vector3.Zero // fully pinned
// UNCONDITIONAL (grounded or airborne):
constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor
```
`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by
adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length)
vs start/max. See §ConstraintScope for why acdream never arms this.
### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37)
```
return constraint_distance_max * 0.9f < constraint_pos_offset; // 90% of leash "fully constrained"
```
Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never
arms the constraint, this stays false → jump never blocked here (= TS-35 current
behavior).
### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650)
```
HandleTargetting(): // per-tick, self-throttled
if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle
if target_info != null && target_info.target_position == null: return
if target_info != null && target_info.status == Undefined
&& target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness
target_info.status = TimedOut
PhysicsObj.HandleUpdateTarget(copy(target_info))
for voyeur in voyeur_table.Values.ToList():
CheckAndUpdateVoyeur(voyeur)
last_update_time = PhysicsTimer.CurrentTime
CheckAndUpdateVoyeur(voyeur):
newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon)
if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move
SendVoyeurUpdate(voyeur, newPos, Ok)
GetInterpolatedPosition(quantum):
pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos
```
`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify
against named-retail before treating literally, per ACE's own `// ref?` caveats).
### 2e. The voyeur round-trip (peer-to-peer position notification)
```
Watcher W wants to track target T:
W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q)
→ W.target_info = new TargetInfo(...)
→ T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table
→ T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot
→ W.receive_target_update(info) → W.ReceiveUpdate(info)
→ W.target_info updated; W.HandleUpdateTarget(info)
→ W.movement_manager.HandleUpdateTarget (MoveToManager steers)
→ W.position_manager.HandleUpdateTarget (StickyManager follows)
Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur):
if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget
W stops: W.ClearTarget → T.remove_voyeur(W.id)
T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld
T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported)
```
This is exactly what the AP-79 adapter approximates (poll target pos, feed
HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**:
same moveto behavior + correct sticky + timeout/exit/teleport events.
---
## 3. Two consumers of set_target (both radius 0.5)
| Caller | quantum | Meaning |
|---|---|---|
| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows |
| `StickyManager.StickTo` | **0.5** | throttled resend |
Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and
both receive updates through `CPhysicsObj::HandleUpdateTarget →
{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the
`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the
real TargetManager; StickyManager gets its own seam to the same TargetManager.
---
## 4. acdream integration seams (delegate contracts)
The Core classes stay engine-agnostic via ctor-injected seams (same convention as
MoveToManager). Per entity the App layer supplies:
- `getPosition : () → Position` (world-space, cell + frame)
- `getVelocity : () → Vector3`
- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally
needed here; today MoveToManager passes 0** — see the P4 note)
- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed
- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate
- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity
seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's
`_entitiesByServerGuid`.
- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky)
- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion)
- clock : `() → double` (real clock, as R4-V5 fixed)
`getObjectA` returns a small handle exposing the OTHER entity's
`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live
Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve.
---
## 5. Sub-slice plan
### R5-V1 — Core classes + conformance tests (NO wiring; low risk)
New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and
`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split):
- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime,
StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget).
- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset).
- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset).
- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`.
- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) —
keep it; PositionManager's interp slot delegates to a thin adapter or is left null
for the player. (Interp is NOT an R5 retirement target; don't re-port it.)
- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to
each other. Golden values from ACE + the decoded math above. Tests:
sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained
90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport,
ReceiveUpdate match+heading-fallback, the full W↔T round-trip.
- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired).
### R5-V2 — Wire TargetManager per-entity; retire AP-79
- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the
MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum
seams at `TargetManager.SetTarget`/`ClearTarget`/quantum.
- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's
TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in
`TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order).
- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the
manual poll blocks — the voyeur round-trip replaces them.
- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature
chasing the player still tracks + moves identically (the exact behavior AP-79 drove).
### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid
- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo →
PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`.
- Integrate `PositionManager.adjust_offset` into the per-frame body integration
(the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is
the load-bearing wiring: sticky steer must ADD to the body's per-tick motion.
- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order).
- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed):
`0x1 StickToObject``stick_to_object(guid)``PositionManager.StickTo`;
`0x2 StandingLongJump``MotionInterpreter.StandingLongJump`.
- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server
/follow-style sticky moveto or a moving-platform stick) holds the target instead of
stopping.
### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural;
must keep the 183-case + funnel + moveto suites green UNMODIFIED).
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag,
raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at
`RetailObserverTraceConformanceTests.cs:33`).
- Final register/ISSUES/roadmap/memory + successor handoff.
---
## Constraint scope (why TS-35 stays open)
ConstraintManager is the **server-position rubber-band leash**, armed ONLY from
`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor =
self or server-received position) with two constants from `GetStart/MaxConstraintDistance`
(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values
unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox
leash (its position reconciliation is separate), so:
- Port the class + wire it into PositionManager (IsFullyConstrained routes through it;
adjust_offset participates in the chain) for structural faithfulness.
- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two
unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's
current behavior. Update TS-35's `where` to point at the now-real-but-unarmed
ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two
unknown constants.
## Open verification items (carry into V1 tests / cdb)
1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming,
deferred). File as issue.
2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f /
sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail.
3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift
(recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword).
4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs
(`TargetInfo::TargetInfo(&copy, src)` at every fan-out); port as copy.
## Do-NOT-re-port (already shipped)
MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter
(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1),
RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote
Interp — not an R5 target).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,191 @@
# R5 wiring handoff — ARC DONE 2026-07-05 (V1-V5 all shipped)
> **ARC CLOSE-OUT 2026-07-05.** All five slices landed: V1 (Core classes,
> `3d89446d`), V2 (TargetManager voyeur wiring, AP-79 retired, `fffe90b3`),
> V3 (sticky melee #171, three slices, TS-39 retired, gate PASSED), V4
> behavioral items (head stance dispatch + #164 + mt-0 flags, `f423884b`,
> gate PASSED), **V5 (MovementManager facade — structural, zero behavior
> change)**: `src/AcDream.Core/Physics/Motion/MovementManager.cs` (retail
> acclient.h `/* 3463 */`; MakeMoveToManager 0x00524000, PerformMovement
> 0x005240d0, UseTime 0x005242f0, HitGround 0x00524300, HandleExitWorld
> 0x00524350, CancelMoveTo 0x005241b0, HandleUpdateTarget 0x00524790,
> IsMovingTo 0x00524260) owns each entity's interp+moveto pair. The three
> wiring sites (`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the
> chase harness) construct through `MoveToFactory` + `MakeMoveToManager()`
> — the factory closure is the acdream stand-in for retail's
> physics_obj/weenie_obj backpointers; `RemoteMotion.Motion/.MoveTo` and
> `PlayerMovementController.MoveTo` are now child VIEWS of the facade
> (`RemoteMotion.Movement` / `PlayerMovementController.Movement`), so the
> comment-dense call sites read unchanged. Relay call sites repointed:
> both landing HitGround pairs + the player landing pair, despawn
> HandleExitWorld, TickRemoteMoveTo + the player Update UseTime,
> RouteServerMoveTo (now takes the facade; routes through the retail
> PerformMovement dispatch), InstallSpeculativeTurnToTarget, both host
> HandleUpdateTarget/InterruptCurrentMovement closures, TS-36 interrupt
> chains. NOT absorbed (per slice spec): unpack_movement stays App
> (RouteServerMoveTo + the UM heads), TS-42 per-tick order untouched (R6),
> #170/#171 gate-passed machinery untouched. 15 facade conformance tests
> (`MovementManagerTests.cs`); suite 4052 green, protected suites
> unmodified. Register: TS-41/TS-42 wording freshened, AD-36 retire note
> corrected (facade half closed; no new rows). **Open follow-ons: #167
> ConstraintManager arming (TS-35), R6 per-tick order (TS-42), TS-43
> remote teleport hook.** Next work per the milestones doc: M1.5 critical
> path — #137 dungeon collision, #138 teleport-OUT, A7 dungeon lighting.
(Original handoff below, kept for the trail.)
Successor to `2026-07-03-r5-entry-handoff.md`. **R5-V1 shipped** (`3d89446d`):
the retail movement-manager family is ported to Core as faithful, fully-tested,
UNWIRED classes. The remaining slices (V2/V3/V4) are GameWindow integration
whose acceptance is **visual verification against the running client** — the one
thing the CLAUDE.md says requires the user. Do them one at a time, visual-gate
each, do NOT stack.
## What V1 landed (`3d89446d`, full suite 4006 green)
New Core (`src/AcDream.Core/Physics/Motion/`), all with conformance tests:
- `StickyManager`, `ConstraintManager`, `PositionManager` (facade),
`TargetManager` + `TargettedVoyeurInfo`, `IPhysicsObjHost` (the CPhysicsObj
back-pointer seam the App implements per entity), `MotionDeltaFrame`.
- `TargetInfo` extended to the full retail 10-field struct (additive defaults).
- `MoveToMath`: `CylinderDistanceNoZ` (signed), `NormalizeCheckSmall`,
`GlobalToLocalVec`.
- **Rename**: `AcDream.Core.Physics.PositionManager` (the misnamed remote
anim+interp combiner) → `RemoteMotionCombiner`. This freed the name for the
faithful facade and removed the ambiguity that breaks any file importing both
`AcDream.Core.Physics` + `.Motion`**GameWindow imports both**, so the
wiring below could not compile without this rename.
Decomp + ACE cross-ref + decoded math: this directory (`r5-*-decomp.md`,
`r5-ace-crossref.md`, `r5-port-plan.md`). The port plan §2 has the decoded x87
mush (sticky steer, constraint taper, voyeur gates) — trust it over the raw BN.
## The wiring model (all slices)
acdream has no per-entity `CPhysicsObj`. Introduce one `IPhysicsObjHost` per
entity — **in a dedicated App class `EntityPhysicsHost`, NOT inline in
GameWindow** (code-structure rule #1). GameWindow keeps a
`Dictionary<uint, EntityPhysicsHost> _physicsHosts`; each host's `GetObjectA`
is `id => _physicsHosts.GetValueOrDefault(id)` (cross-entity resolve for the
voyeur round-trip). Construct a host in `EnsureRemoteMotionBindings` (remotes)
and `EnterPlayerModeNow` (player); remove it + `NotifyVoyeurOfEvent(ExitWorld)`
on despawn/exit-world.
Host accessor mapping (behavioral-equivalence anchor — match AP-79 exactly):
- `Position` = the entity's **`WorldEntity.Position`** (world-space) via
`_entitiesByServerGuid[id].Position`. This is EXACTLY the source the AP-79
poll used for a target (`trackedEnt.Position`), so for the moveto case
(quantum 0, `GetInterpolatedPosition` = Position) the voyeur system delivers
the identical position the adapter did.
- `Velocity` = body velocity (only used for quantum>0, i.e. sticky).
- `Radius` = setup cylsphere radius (today MoveToManager passes 0 — the P4 note;
wiring the real radius here is the "finally needed" bit).
- `InContact` = transient Contact bit (constraint gate; V3).
- `MinterpMaxSpeed` = `interp.GetMaxSpeed()` or null (sticky speed; V3).
- `CurTime`/`PhysicsTimerTime` = the real clocks (R4-V5 fixed the remote clock;
reuse the same epoch-seconds source; the player uses `SimTimeSeconds`).
- `HandleUpdateTarget` = fan to the entity's `MoveToManager.HandleUpdateTarget`
(+ `PositionManager.HandleUpdateTarget` once V3 adds it).
### V2 — wire TargetManager, retire AP-79 (behaviorally a no-op refactor)
Repoint MoveToManager's `setTarget`/`clearTarget`/`getTargetQuantum`/
`setTargetQuantum` seams at `host.TargetManager`. Per tick, call
`host.TargetManager.HandleTargetting()` BEFORE `mtm.UseTime()` (retail
`UpdateObjectInternal` order) — in `TickRemoteMoveTo` (remotes, GameWindow.cs
~4469, both call sites ~9688/9911) and the player pre-Update block
(GameWindow.cs ~7994). **Every entity that can be a target must tick
HandleTargetting** — the creature-chase target is the player, so the player's
host MUST tick it (that's what pushes updates to the chasing creatures).
Delete the AP-79 fields (`RemoteMotion.TrackedTarget*`, the `_playerMoveToTarget*`
GameWindow fields) and the two manual poll blocks. **Delete the AP-79 register
row same commit.** Keep the 183-case/funnel/moveto suites green (unmodified).
Behavioral-equivalence: the voyeur radius = MoveToManager's `set_target` radius
(0.5) = AP-79's `TrackedTargetRadius`; both deliver `HandleUpdateTarget(Ok)`
when the target drifts >radius; both deliver ExitWorld on despawn. Identical for
the moveto case. **VISUAL GATE**: a server-directed creature still chases the
player, and player auto-walk-to-object still tracks — no visible change, confirm
nothing broke.
Lifecycle watchouts: (1) when a watcher despawns, its host removal must let the
target drop the dead voyeur (RemoveVoyeur on ClearTarget, or prune on send
failure). (2) The immediate-snapshot-on-subscribe (retail AddVoyeur) means the
watcher gets one `HandleUpdateTarget(Ok)` the instant it sets a target — matches
AP-79's `!FedOnce` first delivery. (3) HandleTargetting self-throttles to 0.5s;
the AP-79 poll ran every frame but only DELIVERED on drift — same effective
cadence for delivery, but a target moving fast between 0.5s ticks sends less
often. If chase feels choppier than AP-79, that's the throttle — retail-faithful,
but note it.
### V3 — wire PositionManager (sticky), retire TS-39, apply mt-0 flags
> **STATUS 2026-07-04: SHIPPED + visual gate PASSED** ("Looks good, ship it";
> three slices: `5bd2b8bc` binding+radii, `7a823176` UP-snap suppression while
> stuck TS-44, `69966950` deep-overlap sign pin AP-82)
> — sticky seams bound (StickTo/Unstick/UnstickFromObject, remote + player),
> AdjustOffset at the UpdatePositionInternal slot (combiner chained as the
> interp stage in the remote-player branch; pre-sweep compose in the NPC
> branch + player physics tick), UseTime at the UpdateObjectInternal tail,
> real setup cylsphere radii threaded, exit-world/teleport teardown (remote
> teleport gap = TS-43), SERVERVEL yields to a stuck entity (TS-41). TS-39
> retired. **The mt-0 wire flags (0x1 StickToObject / 0x2 StandingLongJump)
> were NOT part of the user-approved #171 scope — they move to V4.**
Add a `PositionManager` to each host. Bind `MoveToManager.StickTo →
host.PositionManager.StickTo`, `MoveToManager.Unstick → host.PositionManager.UnStick`.
The load-bearing part: integrate `host.PositionManager.AdjustOffset(deltaFrame,
quantum)` into the per-frame body integration at the composed-delta chokepoint
(retail `UpdatePositionInternal` — in acdream that's where `RemoteMotionCombiner.
ComputeOffset` runs, GameWindow ~9716; sticky steer must ADD to the body's
per-tick motion). Per tick `host.PositionManager.UseTime()` AFTER
`mtm.UseTime()`. Apply the mt-0 wire flags (`UpdateMotion.cs` parses both,
unconsumed): `0x1 StickToObject``host.stick_to_object(guid)`
`PositionManager.StickTo`; `0x2 StandingLongJump`
`MotionInterpreter.StandingLongJump`. **Delete the TS-39 register row same
commit.** **VISUAL GATE**: a sticky scenario (server /follow-style sticky moveto
or a moving-platform stick) HOLDS the target instead of stopping-and-drifting.
### V4 (capstone) — MovementManager facade + #164 + head-stance dispatch + docs
> **STATUS 2026-07-04: the three BEHAVIORAL items SHIPPED + visual gate
> PASSED** ("looks good", `f423884b`): head style-on-change at both GameWindow routing heads
> (@00524502-0052452c, all movement types), #164 action-replay Autonomous
> bit (raw 305982), mt-0 wire flags consumed (0x1 stick_to_object
> 0x005127e0 → PositionManager.StickTo at both case-0 tails; 0x2 →
> `Motion.StandingLongJump`, unconditional per @0052458e). Conformance:
> stance-on-arm harness scenario + the autonomy funnel test; suite 4041
> green. **The MovementManager facade DEFERRED to its own slice** (the
> handoff's own "optional if the arc runs long" clause — this arc ran two
> full gate cycles). **→ SHIPPED 2026-07-05 as R5-V5; see the arc
> close-out banner at the top of this doc.**
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner
(structural; keep 183-case/funnel/moveto green UNMODIFIED). Optional if the
arc runs long — the retirements above don't need it.
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy
flag, raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
- Head stance-change dispatch for mt 6-9 (the `RetailObserverTraceConformance
Tests.cs:33` "S3 wires the unpack-level style-on-change" exclusion — retail's
`unpack_movement` head does `InqStyle != wire-style → DoMotion(style)`
independent of movement type; acdream applies style only on the mt-0 path).
- Final register/ISSUES/roadmap/memory + successor handoff.
## Open items carried
- **#167**: ConstraintManager leash-arming + the two unknown x87 constants
(`GetStart/MaxConstraintDistance`) — deferred; needs cdb/Ghidra. TS-35 stays
open (register corrected: the mechanism is the leash, not doorway-jamming).
- Verify against named-retail before treating as ground truth: the sticky/target
constants (StickyRadius 0.3 / StickyTime 1.0 / follow ×5 / fallback 15;
HandleTargetting 0.5s throttle / 10s staleness) — ACE values, cross-checked
against the mush but ACE flagged its own `// ref?` uncertainty on the
TargetInfo copy semantics (ported as copy — retail copy-constructs at every
fan-out).
## Load-bearing lessons (from V1)
- The `Physics.PositionManager` name collision was a REAL compile break for any
file importing both namespaces, not cosmetic — the recon agent missed the
existing (misnamed) class entirely (grepped for retail semantics). Renaming it
was the "do it right" fix. Lesson: grep for the NAME too, not just the concept.
- ConstraintManager is NOT a general joint/constraint system — it's a narrow
server-position rubber-band leash (3 call sites total: SmartBox arm, teleport
disarm, jump-gate read). Don't over-scope it.
- The voyeur system is peer-to-peer intra-client: it needs EVERY entity to be a
host + tick HandleTargetting, or cross-entity delivery silently no-ops.

View file

@ -0,0 +1,111 @@
# Session handoff — R5-V1/V2 + world-loading fixes (2026-07-03, late)
Fresh session enters HERE. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`, tree CLEAN at `64f83d7c`. Full suite **4007**
green (+ the R5-V1 conformance tests + the #168 relocate test). Everything below
is committed.
## What landed this session
| Commit | What |
|---|---|
| `3d89446d` | **R5-V1** — PositionManager facade + StickyManager + ConstraintManager + the full **TargetManager voyeur system** ported to Core, fully tested, UNWIRED. Renamed the misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`. Decomp/ACE/plan in `docs/research/2026-07-03-r5-managers/`. |
| `2b5e8a67` | R5-V1 docs: wiring handoff, register TS-35 correction, ISSUES #167 (ConstraintManager leash unarmed). |
| `fffe90b3` | **R5-V2** — wired the TargetManager voyeur system per-entity via `EntityPhysicsHost` (App), retired the AP-79 poll adapter. **Verified live** — this is why remote creatures now chase the player. |
| `315af02f` | **fix #168** — the pending-bucket trap (`RelocateEntity` couldn't recover an entity stranded in `_pendingByLandblock`). Invisible player / disappear-on-run-out. |
| `9b06a9b8` | **fix #169** — the cold-spawn streaming "hole" (far login-spawn diffs against the half-loaded startup window; residence marked before loads land → hole). Now `ForceReloadWindow`s on a far login-spawn. |
| `7c5bc97c` | ISSUES #168/#169 filed DONE. |
| `64f83d7c` | ISSUES #170 filed (creature chase/attack animation divergence). |
**The user's headline ask this session** — "the world doesn't load / character
disappears" — is FIXED and verified live (`0xADAF` loads, player transitions
`PENDING → DRAWSET PRESENT`). Root-caused to two pre-existing streaming bugs;
R5-V2 was verified line-by-line to be read-only w.r.t. position/cell/landblock/
streaming and RULED OUT as the cause. Durable lesson: `feedback_streaming_residence_race`.
## NEXT — two ready work streams (Claude picks; both are teed up)
### Option A — #170: remote creature chase+attack renders wrong vs retail
**Well-scoped, user-confirmed via retail side-by-side (the oracle).** A monster
chasing+attacking the player glides, over-plays attacks, and shows uniform/wrong
attack animations; retail (same local ACE) renders it correctly → the divergence
is CLIENT-side. Motion trace (`ACDREAM_DUMP_MOTION`, guid 0x80000244): ACE sends
`mt-0 stance 0x3C``mt-6 chase spd 2.03` → a stream of `mt-0` attacks
`0x62/0x63/0x64 spd 0.97`; acdream shows a MOTIONDONE `pending=True` loop.
Decomposes into THREE sub-bugs (full detail in ISSUES #170):
1. **Wrong/uniform attack anims** → likely **#159** (CombatAnimationPlanner uses
2013-decomp command numbering, not ACE/DRW — `0x10000062/63/64` misclassify).
This one has a KNOWN fix path (renumber `CombatAnimationMotionCommands` to
`DatReaderWriter.Enums.MotionCommand` values; parity test).
2. **Over-frequency / stuck** — the `pending_motions` queue completes+re-queues
in a tight loop. R3/R4 animation-sequencer / MotionDone territory (grep the
named decomp for how retail's `CMotionInterp` handles an attack UM stream
arriving over an active moveto — attack one-shots should play over locomotion
and RETURN to it, not wedge).
3. **Glide** — position moves (mt-6 + UP dead-reckon) but no locomotion legs play
(attacks override) → smooth slide. AP-80 / #160 dead-reckon-vs-animation family.
Entry points: `MotionInterpreter`/`AnimationSequencer` attack dispatch +
`pending_motions`; `CombatAnimationPlanner` (#159); `ServerControlledLocomotion`.
Start with #159 (bounded), then trace the pending-motions loop with the motion
dump. **Retail is the oracle — the user runs it side-by-side; ask for a
frame-by-frame retail comparison, don't guess.**
### Option B — R5-V3: wire PositionManager sticky, retire TS-39, apply mt-0 flags
The paused R5 continuation. Full plan in
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` (§V3). Bind
`MoveToManager.StickTo/Unstick → PositionManager` (now that V2 gives each entity
an `EntityPhysicsHost`, adding a `PositionManager` to it is the natural next
step), integrate `adjust_offset` into the body tick, apply the UpdateMotion mt-0
flags (0x1 sticky-guid → `stick_to_object`, 0x2 → `StandingLongJump`). Retires
TS-39. Then R5-V4 (MovementManager facade + #164 + head-stance dispatch).
**Recommendation:** #170 is more user-visible (they just watched it break) and is
now well-scoped; R5-V3 is the "finish what we started" path. Either is fine —
Claude's call per work-order autonomy. #170's part 1 (#159) is the smallest
concrete win.
## R5 arc status
- **V1** (Core managers + tests) — DONE `3d89446d`.
- **V2** (voyeur wiring, retire AP-79) — DONE `fffe90b3`, verified live.
- **V3** (sticky, retire TS-39, mt-0 flags) — PENDING. `r5-wiring-handoff.md` §V3.
- **V4** (MovementManager facade + #164 + head-stance dispatch) — PENDING. §V4.
- Open R5-adjacent: #167 (ConstraintManager leash unarmed + 2 unknown x87 consts),
TS-35 (stays open until the leash is armed), #164 (action-replay Autonomous bit).
## Debug apparatus that proved decisive (reuse for #170)
- `ACDREAM_PROBE_ENT=1``EntityVanishProbe`: `[ent] APPEND → PENDING/LOADED`,
`DRAWSET PRESENT/ABSENT`, `[dyn] player DRAWN/CULLED`. Separates "not in draw
set" from "present-but-culled." A one-line `[lb] ADD` in `GpuWorldState.AddLandblock`
(added then stripped) exposed the streaming hole as a missing Y-band — re-add
it if you chase another streaming gap.
- `ACDREAM_DUMP_MOTION=1` — every inbound `UM` (guid, stance, cmd, speed) + the
resulting `SetCycle` + `[MOTIONDONE]`. THE tool for #170 — it's how the
attack-stream-over-chase was found.
## Session gotchas (environment)
- **The test character `+Acdream` (0x5000000A) is saved OUT in the wilderness**
(landblock ~`0xADAF`/`0xAEAE`, ~1 km SE of Holtburg), from an earlier run of
the #168 disappear bug. It spawns there every login. **With the #169 fix that
now loads correctly** (far login-spawn ForceReloadWindows), so it's testable —
and there are Mite Scamps + a Mosswart Feeder right at the spawn (handy for
#170). To get back to Holtburg, the user GM-teleports.
- **Client lifecycle: the USER manages it.** Launch with plain `dotnet run
--no-build` in the background (never pre-close/kill). If a rebuild is locked by
a running client (`The file is locked by AcDream.App`), ASK the user to close
it — don't kill it.
- **ACE stale session:** a hard-closed or rapidly-relaunched client leaves ACE
thinking the character is still in-world → `session failed: CharacterList not
received` for ~3 min. Graceful close (window X / WM_CLOSE, exit 0) clears in
~5 s. Don't retry-spam; wait ~160 s if it's stuck. This session hit it once
from rapid relaunches.
- PowerShell `Tee-Object`/`Out-File` write UTF-16 → the plain-text grep needs
`tr -d '\000'` first (the launch logs read as UTF-16). Used throughout.
## Pointers
- R5 decomp/plan: `docs/research/2026-07-03-r5-managers/` (decomp per manager,
ACE cross-ref, port plan, wiring handoff).
- ISSUES: #167 (constraint leash), #168/#169 (DONE this session), #170 (creature
animation), #159 (combat command numbering), #160/#165/#166 (remote-motion).
- Memory: `feedback_streaming_residence_race`, and the R5 research index entry in
`MEMORY.md` (animation-sequencer deep-dive line).
- Roadmap Phase R: R5-V1/V2 shipped; V3/V4 next.

View file

@ -0,0 +1,188 @@
# Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04
> **⚠ SUPERSEDED (2026-07-04, second session): the residual is FIXED — pending visual gate.**
> The "Ready stop-node backlog drains a beat slower" framing below was DISPROVEN by a
> full-stack offline harness (`tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs`)
> plus corrected per-guid attribution of the launch-drainq.log evidence. The Core
> drain/turn/run machinery is healthy; both handoff hypotheses ((a) tick counts,
> (b) drain trigger rate) are moot. The real mechanism: the per-tick branch arbitration
> sent any UP-receiving NPC down the SERVERVEL leg, which **skips `MoveToManager.UseTime`**
> — the armed moveto was starved for the whole server-side chase (funnel: 16 arms →
> 11 dispatched turns → 1 run install), legs stayed Ready while the body glided on
> UP-synthesized velocity. Retail runs `MovementManager::UseTime` unconditionally
> (`UpdateObjectInternal` 0x005156b0 @0x00515998). Fix: armed movetos always take the
> MOVETO leg (GameWindow `TickAnimations`, `moveToArmed` gate; register TS-41 narrowed,
> TS-42 added for the one-frame drain-order divergence). Current status + next steps
> live in `docs/ISSUES.md` #170. This doc remains as the evidence record for the flood
> fix (`427332ac`) and the cdb apparatus.
Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix +
env-gated probes). This session root-caused #170 end-to-end with **live retail cdb
tracing** + acdream runtime probes and landed a **verified partial fix**. One
residual remains (the run isn't fully sustained). This doc is the SSOT; the paste
prompt is `docs/research/2026-07-04-170-pickup-prompt.md`.
## TL;DR
A monster chasing a fleeing player renders as a **slide** in acdream (glides toward
you in an idle/attack pose) vs **runs to close, stops to swing** in retail (same
local ACE). Root cause, proven:
- The chase **run cycle is manufactured client-side** from the `mt-6` MoveToObject
stream: `HandleUpdateTarget → MoveToObject_Internal → (TurnToHeading node
completes via UseTime) → BeginMoveForward → _DoMotion(RunForward)` sets the
motion-table **substate = RunForward** (the legs). `RunForward` is NEVER on the
wire — the server only sends mt-6 + the attack UMs.
- `MoveToManager.BeginTurnToHeading` (retail `0x00529b90`) bails while
`CMotionInterp.motions_pending` is non-empty (**retail-faithful guard**).
- acdream's `pending_motions` **exploded to ~1.3M entries** because the NPC
per-tick called `rm.Motion.apply_current_movement` **every frame**, re-dispatching
the whole interpreted state (stance + attack + stops) and appending a
`pending_motions` node each time. `MotionsPending()` stayed permanently true →
the chase turn never started → `BeginMoveForward/RunForward` ~never fired → slide.
- **FIX (landed `427332ac`):** delete the per-frame `apply_current_movement` in the
grounded remote-NPC path (`GameWindow.cs` ~9992). Retail dispatches per motion
**event** (per UM), never per frame.
**Result (verified live):** flood 1.3M → depth ~1 (add≈done); "stuck in attack
animation" GONE (user-confirmed); run cycle installs (`BeginMoveForward` 1→10,
`RunForward` held 0→7). **PARTIAL** — still not fully sustained (below).
## The one remaining residual (= the next session's job)
The run isn't sustained: `BeginTurnToHeading` is still blocked
`motionsPending=True` **256/272 (94%)** of the time, because a small **`Ready`
(0x41000003) stop-node backlog** keeps `pending_motions` from ever fully emptying
between swings. acdream gets ~10 run-starts to **retail's 21**, so it now
**twitches forward + glides** (short run bursts + idle) instead of a clean
run-then-stop.
**Where the `Ready` backlog comes from (traced, not guessed):**
- `MotionInterpreter.StopInterpretedMotion` (`MotionInterpreter.cs:3254`) appends a
`Ready` `pending_motions` node (line 3292) whenever the stop **succeeds**
(`sink.StopMotion``CMotionTable.StopSequenceMotion` returns true).
- `StopSequenceMotion` (`CMotionTable.cs:559`) returns **false** for a *redundant*
sidestep/turn stop (Case A: not our substate; Case B: no matching modifier) — so
those correctly DON'T queue `Ready`. The `Ready` nodes that DO accumulate come
from stops that **succeed**: chiefly the per-UM tail
`StopInterpretedMotion(TurnRight)` (`MotionInterpreter.cs:3008`) stopping the
MoveTo's OWN steering turn, plus the MoveTo-cancel `StopCompletely`.
- Those `Ready` nodes drain via `MotionDone` (fired 1:1 by `MotionTableManager`
per completed `_pendingAnimations` entry — `AnimationDone` per AnimDone hook
`GameWindow.cs:10306` + `UseTime``CheckForCompletedMotions` 0-tick sweep at
`10309`). They drain **a beat slower** than they add → backlog grows (lag 1→10
over a chase). Retail hits `add_to_queue == MotionDone` **exactly** (cdb-proven).
**The decisive open question for the fix:** WHY do acdream's `Ready` stop-nodes
drain slower than retail's? Two concrete hypotheses to test with a retail cdb
trace (retail is the oracle; do NOT guess in this verbatim-ported R2/R3 machinery):
1. **Tick count.** acdream's `Ready` stop entries have `outTicks > 0` (from
`StopSequenceMotion` Case A → `GetObjectSequence(styleDefault, stopCall:true,
out outTicks)`), so they wait for `AnimationDone` instead of draining
immediately via the per-frame 0-tick `CheckForCompletedMotions`. Retail's may be
0-tick. → cdb: break `CMotionTable::StopObjectMotion`/`GetObjectSequence` on a
live chasing monster, read the returned tick count for a turn-stop.
2. **Drain trigger rate.** Retail may fire `CheckForCompletedMotions`/`MotionDone`
more aggressively than acdream's once-per-frame `Manager.UseTime`. → cdb: count
`CMotionInterp::MotionDone` vs `add_to_queue` (already have the script:
`scratchpad/cdb-drain.cdb` — retail = add==done) and add
`CPhysicsObj::CheckForCompletedMotions`/`MotionTableManager::UseTime` counts.
Target acceptance: acdream's `pending_motions` drains to `add==done` (fully empties
between swings like retail) → `BeginTurnToHeading` proceeds per mt-6 arm →
`BeginMoveForward ≈ MoveToObject` (retail was 21≈22) → the creature runs to close
distance, plants to swing. **Visual gate:** user runs acdream + retail side-by-side.
## Evidence table (live traces this session)
| Metric | retail (cdb) | acdream BEFORE fix | acdream AFTER fix (`427332ac`) |
|---|---|---|---|
| `pending_motions` add vs done | **254 == 254** | 1.37M vs 5.7K | 425 vs 424 |
| max `pending_motions` depth | shallow | **1,332,575** | ~12 |
| `BeginMoveForward` (run installs) / chase | 21 | 1 | 10 |
| `MoveToObject` arms / chase | 22 | (7 in an attack-heavy cap) | 32 |
| `HandleUpdateTarget` (voyeur re-drive) | 689 | 44 | fires |
| `BeginTurnToHeading` motionsPending True/False | (empties often) | n/a | **256 / 16** |
| lingering queue contents | — | 0x8000003C×671K (stance) | **0x41000003 (Ready) backlog** |
## Apparatus (all in place at `427332ac`; reuse, then strip when #170 closes)
**acdream env-gated probes** (`ACDREAM_MVTO_DIAG=1`, alongside `ACDREAM_DUMP_MOTION=1`):
- `MoveToManager.cs` `s_mvtoDiag`: `[mvto] BeginMoveForward cmd=…`, `HandleUpdateTarget
… match=… init=… mtState=…`, `CancelMoveTo (real) wasType=…`, `UseTime enter/dispatch
node=…`, `BeginTurnToHeading motionsPending=… curCmd=…`.
- `MotionInterpreter.cs` `s_drainDiag`: `[drain] add=… done=… lag=… depth=…` (aggregate
add_to_queue vs MotionDone) + `[drainq] depth=… q=[…]` (queue CONTENTS when depth≥2 —
this is what named `Ready` as the lingering node).
- `GameWindow.cs`: `[npc-tick] guid=… branch=SERVERVEL|MOVETO` (which per-tick branch),
+ the earlier `UM ↳ actions …` inbound-action dump (capture aid).
**Launch (user manages lifecycle; graceful close):**
```
$env:ACDREAM_DUMP_MOTION="1"; $env:ACDREAM_MVTO_DIAG="1" # + ACDREAM_LIVE/DAT_DIR/host/port/user/pass
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
In-world: **aggro a Mite Scamp at the wilderness spawn (~0xADAF), RUN AWAY so it
CHASES** (the bug is the chase, not attack-in-place — no chase ⇒ no `[mvto]` lines).
Logs are UTF-16 → `tr -d '\000'` before grep.
**Retail cdb toolchain** (Step -1; retail is the oracle for the residual):
- Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb`
(`py tools/pdb-extract/check_exe_pdb.py "…/acclient.exe"`). cdb at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
- Working scripts saved in the scratchpad this session: `cdb-lookup.cdb` (symbol
names), `cdb-count.cdb` / `cdb-chase.cdb` (BeginMoveForward/MoveToObject/
HandleUpdateTarget/RunForward-dispatch counts), `cdb-drain.cdb`
(add_to_queue vs MotionDone vs motions_pending). Pattern: count + `gc`,
auto-`qd` after N `move_to_interpreted_state` hits (user makes a monster chase
retail during the trace). Key addrs: `MoveToManager::BeginMoveForward 0x00529a00`,
`_DoMotion 0x00529010`, `CancelMoveTo 0x00529930`, `MoveToObject 0x00529680`,
`HandleUpdateTarget 0x0052a7d0`, `UseTime 0x0052a780`;
`CMotionInterp::move_to_interpreted_state 0x005289c0`, `DoInterpretedMotion
0x00528360`, `add_to_queue 0x00527b80`, `MotionDone 0x00527ec0`, `motions_pending
0x00527fe0`, `get_state_velocity 0x00527d50`.
## DO-NOT-RETRY / superseded
- **The `eb423fb7` "MovementManager coexistence / R5-V4" hypothesis is WRONG** — the
attack UM cancelling the MoveTo is retail-faithful and NOT the cause. Superseded by
this doc. (Register/ISSUES updated.)
- The `d2ccc80e` velocity fix (`get_state_velocity``set_local_velocity` each
grounded tick) is CORRECT but **position-only** — it does NOT fix the legs; keep it.
- `CombatAnimationPlanner` (#159, fixed `2de5a011`) was a **red herring** for the
Mite Scamp (its 0x62/63/64 attacks were always in the correct block; the planner
is unwired). Real #170 is the MoveTo/pending_motions chain above.
- `BeginTurnToHeading`'s `if (motions_pending) return` guard is **retail-faithful**
do NOT patch it. Fix the DRAIN so the queue empties, don't remove the guard.
- The per-frame `apply_current_movement` deletion is the ROOT fix for the flood
(retail dispatches per UM). Do NOT re-add it.
## Key file:line map
- `GameWindow.cs` ~9982 (grounded remote-NPC branch; the deleted per-frame
apply_current_movement + the kept `get_state_velocity` velocity refresh);
~10306/10309 (the pending-motions DRAIN: `Manager.AnimationDone` per AnimDone
hook + `Manager.UseTime`); ~4251 `EnsureRemoteMotionBindings` (MoveToManager +
TargetManager voyeur wiring, `getSelfId=serverGuid` correct).
- `MoveToManager.cs`: `BeginMoveForward:741`, `BeginTurnToHeading:821`
(`if (_interp.MotionsPending()) return` at ~833), `HandleTurnToHeading:1164`,
`HandleUpdateTarget:1229`, `MoveToObject_Internal:1333`, `UseTime:953`,
`CancelMoveTo:1475`.
- `MotionInterpreter.cs`: `AddToQueue:2297` (pending_motions add),
`MotionsPending:2298`, `MotionDone:2318` (pops one head),
`ApplyInterpretedMovement:2920` (style→forward→sidestep-stop→turn-stop),
`StopInterpretedMotion:3254` (adds `Ready` on success, line 3292).
- `CMotionTable.cs`: `StopSequenceMotion:559` (false for redundant stop),
`StopObjectMotion:640`, `GetObjectSequence` (Branch 1 substate write; Branch 3
action overlay — verified faithful).
- `MotionTableManager.cs`: `AnimationDone:290`, `CheckForCompletedMotions:322`,
`UseTime:342`, `PerformMovement:409` (StopInterpreted → AddToQueue(Ready,ticks)).
## Session gotchas
- Client + retail both hit the SAME local ACE (127.0.0.1:9000). acdream char
`+Acdream` (0x5000000A) spawns in the wilderness (~0xADAF) with Mite Scamps.
- Two workflow tracers returned junk/schema-cap this session; the synthesis+verify
carried it BUT its "run cycle from mt-6/BeginMoveForward" map was later REFUTED for
attack-in-place (BeginMoveForward=0) and CONFIRMED only for the CHASE — always
trace the CHASE scenario, and trust the live cdb over the decomp synthesis.

View file

@ -0,0 +1,55 @@
# Pickup prompt — #170 "sustain the creature run" (paste into a fresh session)
Read `docs/research/2026-07-04-170-creature-run-handoff.md` first — it is the SSOT
for this task and carries the full evidence, the retail cdb numbers, the apparatus,
and the DO-NOT-RETRY list. Then continue #170.
**Where we are:** the primary #170 bug is FIXED and user-verified at `427332ac`
(branch `claude/vigorous-joliot-f0c3ad`). A per-frame `apply_current_movement`
re-dispatch was flooding `CMotionInterp.pending_motions` to ~1.3M entries, which
kept `MotionsPending()` permanently true and blocked the MoveTo chase turn
(`BeginTurnToHeading`), so a chasing creature slid in an idle+attack pose instead
of running. Deleting that per-frame call (GameWindow ~9992) dropped the queue to
depth ~1 (add≈done), killed the "stuck in attack animation", and the run cycle now
installs (`BeginMoveForward` 1→10). The "stuck attack" is confirmed gone by the user.
**Your job — the ONE remaining residual: "sustain the run."** The run isn't
sustained yet: `BeginTurnToHeading` is still blocked `motionsPending=True` ~94% of
the time because a small **`Ready` (0x41000003) stop-node backlog** keeps
`pending_motions` from ever fully emptying between swings. acdream gets ~10
run-starts vs retail's 21, so the creature now twitches-forward + glides instead of
a clean run-then-stop. Retail hits `add_to_queue == MotionDone` EXACTLY (cdb-proven);
acdream's `Ready` stop-nodes drain a beat slower.
**Do this, in order:**
1. Re-read the handoff's "residual" + "DO-NOT-RETRY" sections. In particular: the
`BeginTurnToHeading` `if (motions_pending) return` guard is retail-faithful — DO
NOT patch it; fix the DRAIN so the queue empties like retail.
2. **Retail is the oracle** (Step -1). Retail's cdb is available; the binary matches
`refs/acclient.pdb`. Trace the `Ready`-stop drain on a live chasing monster to
settle the two hypotheses in the handoff: (a) do acdream's `Ready` stop entries
carry `outTicks > 0` (so they wait for `AnimationDone` instead of the per-frame
0-tick `CheckForCompletedMotions`) while retail's are 0-tick? (b) does retail fire
`CheckForCompletedMotions`/`MotionDone` more aggressively than acdream's
once-per-frame `Manager.UseTime`? Reuse `scratchpad/cdb-drain.cdb` +
the addrs in the handoff. Ask the user to make a monster chase their retail char
during the trace.
3. The env-gated acdream probes are already in place (`ACDREAM_MVTO_DIAG=1`) —
`[drainq]` dumps the queue contents (this is what named `Ready` as the lingering
node). Capture a chase (aggro + RUN AWAY) to compare against retail.
4. Implement the faithful drain fix (verbatim-ported R2/R3 machinery — get it EXACTLY
right, no redesign; this is the revert-prone area). Acceptance: acdream
`add==done` (queue fully empties between swings) → `BeginMoveForward ≈ MoveToObject`
per chase → creature runs to close distance, plants to swing. **Visual-gate with
the user (retail side-by-side)** — that is the acceptance test.
5. When it lands + is visually confirmed: **STRIP all the temporary #170 probes**
(`s_mvtoDiag` in MoveToManager.cs, `s_drainDiag` in MotionInterpreter.cs, the
`[npc-tick]` lines + the `UM ↳ actions` dump in GameWindow.cs), move #170 to
Recently-closed in `docs/ISSUES.md` with the SHAs, and update the animation
research/memory index.
**Gotchas:** both acdream (+Acdream char) and retail hit the same local ACE
(127.0.0.1:9000); trace the CHASE (player fleeing), not attack-in-place — no chase ⇒
no `[mvto]` lines; PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep); user
manages client lifecycle (graceful close). Trust the live cdb over the earlier
workflow synthesis (its "run from mt-6" map was refuted for attack-in-place).

View file

@ -0,0 +1,52 @@
# Pickup prompt — #171 sticky melee / R5-V3 (paste into a fresh session)
Read `docs/research/2026-07-04-171-sticky-melee-handoff.md` first — it is the
SSOT for this task (root causes, retail anchors, approved scope, gotchas).
Then implement R5-V3.
**Context:** #170 (chase slide) is CLOSED — gate passed at `4cad626f`; do not
reopen that machinery. During its gate the user observed #171: in a pack
melee, monsters sit partly inside each other with slightly stale facings vs
retail on the same ACE. Investigated + **fix approved by the user 2026-07-04**.
**The two causes (both R5-V3 scope, already register-tracked):**
1. Sticky melee is a no-op (TS-39): ACE arms melee chases with `Sticky`;
retail's arrival hands off to `PositionManager::StickTo`
`StickyManager::adjust_offset` (0x00555430, per-tick 0.3 m gap + facing
tracking). Our `MoveToManager.StickTo`/`Unstick` seams are unbound —
attackers freeze at stale arrival poses.
2. Arrival radii are zero: `getOwnRadius: () => 0f` (the R5-V3 pin in
`EnsureRemoteMotionBindings`) + `RouteServerMoveTo` never sets
`MovementStruct.Radius/Height` — retail/ACE arrive edge-to-edge with
setup-derived radii (ACE `PhysicsObj.MoveToObject` reads the TARGET's
PartArray radius/height).
**Do, in order:**
1. Grep the named decomp for `StickyManager::` + `PositionManager::` bodies
(adjust_offset 0x00555430; per-tick anchors already pinned:
`PositionManager::adjust_offset` @0x00512d0e inside UpdatePositionInternal,
`PositionManager::UseTime` @0x005159b3 in UpdateObjectInternal).
Pseudocode, then port verbatim; cross-check
`references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,PositionManager}.cs`.
Groundwork already in tree: `MoveToMath.CylinderDistanceNoZ` /
`GlobalToLocalVec` / `NormalizeCheckSmall`; `ConstraintManager` (R5-V1) is
the port-style reference. Do NOT arm ConstraintManager (#167 separate).
2. Bind `StickTo`/`UnStick` in `EnsureRemoteMotionBindings` AND the player
wiring; wire PositionManager into the tick at the retail-matching points.
3. Thread real cylsphere radii (own from the entity's Setup; target resolved
at `RouteServerMoveTo`'s MoveToObject branch). Sweep remote + player.
4. Same-commit register bookkeeping: retire TS-39, update the radius pin.
5. Extend `RemoteChaseEndToEndHarnessTests` with a sticky scenario (arrive →
target strafes → follower tracks gap + facing; UnStick on next
PerformMovement). Harness bodies MUST mirror the live RemoteMotion
construction (InWorld=true, RemoteWeenie — see RemoteChaseDrainBisectTests).
6. `dotnet build` + full `dotnet test` green → commit → launch for the user's
visual gate: pack melee side-by-side vs retail, strafe around the pack;
attackers should reshuffle + keep facing like retail. Acceptance is retail
PARITY — some overlap is ACE-server-side and shows on retail too.
**Gotchas:** revert-prone remote-motion area — verbatim port, no redesign;
per-entity probes must print the guid (memory
`feedback_probe_identity_attribution`); PowerShell Tee logs are UTF-16
(`tr -d '\000'` before grep); the user manages client lifecycle (graceful
close); both clients hit the same local ACE (127.0.0.1:9000).

View file

@ -0,0 +1,126 @@
# Handoff — #171 group-melee interpenetration + facing drift (R5-V3: StickyManager + arrival radii) — 2026-07-04
Fresh session picks up HERE for #171. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`, tree CLEAN after the #170 close-out
(`4cad626f`). The fix scope below is USER-APPROVED ("Ok I approve",
2026-07-04). Pickup prompt: `docs/research/2026-07-04-171-pickup-prompt.md`.
## Symptom (user report, #170 gate session)
In a pack melee vs the player, acdream monsters end up **partly inside each
other** with **slightly stale facings**, vs retail on the same local ACE.
The #170 chase itself (turn → sustained run → plant → swing) is CLOSED and
gate-passed — do not reopen that machinery.
## Root causes (code-grounded, investigated 2026-07-04)
1. **Sticky melee is a no-op — register TS-39.** ACE arms EVERY melee chase
with `Sticky | UseFinalHeading | MoveAway | FailWalk`
(`references/ACE/Source/ACE.Server/WorldObjects/Monster_Navigation.cs:406-419`).
The retail arrival chain: `MoveToManager::BeginNextNode` (sticky bit set,
queue empty) reads `SoughtObjectId/Radius/Height` BEFORE `CleanUp` zeroes
them (that ordering IS already ported — `MoveToManager.cs` ~700-714) and
calls `PositionManager::StickTo(tlid, radius, height)`; from then on
`StickyManager::adjust_offset` (0x00555430) runs per tick and holds a
~0.3 m edge gap + facing against the moving target. Every
`MovementManager::PerformMovement` head calls `unstick_from_object`
`PositionManager::UnStick` (our `MoveToManager.PerformMovement:414`
invokes the seam). acdream's `StickTo`/`Unstick` seams are UNBOUND
(GameWindow `EnsureRemoteMotionBindings` + the player wiring bind
neither) → attackers complete-and-freeze at stale arrival poses until the
next wire re-arm. This is the direct cause of BOTH observed symptoms.
2. **Arrival radii are zero.** Retail/ACE arrive EDGE-TO-EDGE: ACE
`PhysicsObj.MoveToObject` (`references/ACE/…/Physics/PhysicsObj.cs:936-956`)
reads the TARGET's `PartArray.GetRadius()/GetHeight()` and passes them into
`MoveToObject_Internal`; the mover's own radius feeds
`GetCurrentDistance`'s `cylinder_distance` when `UseSpheres` (ACE default
TRUE, `DistanceToObject` default 0.6). acdream:
- `GameWindow.EnsureRemoteMotionBindings` binds `getOwnRadius/getOwnHeight
: () => 0f` — the comment IS the R5-V3 pin ("setup cylsphere radius lands
with R5-V3");
- `GameWindow.RouteServerMoveTo` never sets `MovementStruct.Radius/Height`
(target radii) → 0.
Net: every attacker closes ~one body-radius deeper than retail → dogpile.
3. **Server-side caveat:** ACE has no monster-vs-monster avoidance; UP
hard-snaps plant bodies where ACE says (retail hard-snaps identically).
Some overlap is server-authoritative and visible on retail too —
**acceptance is retail parity, not zero overlap.**
Ruled out: the between-snap collision sweep. Remotes run the full
`ResolveWithTransition` (GameWindow ~10100) and `CollisionExemption` keeps
creature cylinders collidable for non-viewer movers.
## Approved fix scope (= the R5-V3 slice, roadmap-aligned)
1. **Port `StickyManager` verbatim** (grep `docs/research/named-retail/
acclient_2013_pseudo_c.txt` for `StickyManager::` first — `StickTo`,
`UnStick`, `adjust_offset` 0x00555430, `HandleUpdateTarget`; cross-check
`references/ACE/Source/ACE.Server/Physics/Managers/StickyManager.cs`).
Groundwork ALREADY in tree (`MoveToMath`): `CylinderDistanceNoZ` (signed —
the adjust_offset gap math), `GlobalToLocalVec`, `NormalizeCheckSmall`
all documented as adjust_offset consumers. `ConstraintManager` (the sibling
PositionManager member) was ported R5-V1 (TS-35, unarmed — #167 is
SEPARATE, do not arm it here).
2. **PositionManager**: the facade that owns Sticky + Constraint
(`references/ACE/…/Managers/PositionManager.cs`). Retail per-tick anchors
pinned this session from the named decomp:
- `PositionManager::adjust_offset` called inside
`CPhysicsObj::UpdatePositionInternal` @0x00512d0e (BEFORE process_hooks);
- `PositionManager::UseTime` called in `UpdateObjectInternal` @0x005159b3
(AFTER `CPartArray::HandleMovement`).
Wire the acdream equivalents at the matching points of the remote tick
(GameWindow `TickAnimations`) and the player path.
3. **Bind the seams**: `MoveToManager.StickTo` → PositionManager.StickTo;
`MoveToManager.Unstick` → PositionManager.UnStick — in
`EnsureRemoteMotionBindings` AND the player wiring (`EnterPlayerModeNow`
area, ~13128). The TargetManager voyeur fan-out already delivers target
info; the R5-V2 comment at GameWindow ~4327 says exactly this:
"PositionManager sticky joins the fan-out in V3" — StickyManager is a
voyeur consumer in retail (its own HandleUpdateTarget).
4. **Thread real radii**: own radius/height from the entity's Setup (the
spawn path already reads `setup.Radius`/`setup.Height` for collision
registration, GameWindow ~4187); target radius/height at the
`RouteServerMoveTo` MoveToObject branch (resolve the target entity's
setup — retail reads the TARGET object, not the wire). Sweep BOTH the
remote and player MoveToManager bindings.
5. **Register bookkeeping (same commit):** retire TS-39; retire/narrow the
radius pin note; new rows for any adaptation introduced.
6. **Conformance:** extend `RemoteChaseEndToEndHarnessTests` with a sticky
scenario — arm a sticky MoveToObject, arrive, then MOVE the target
sideways: assert the follower tracks (gap ≈ stick distance, facing follows)
and `UnStick` fires on the next PerformMovement head. The harness already
models the full tick order; add a PositionManager step where retail has it.
## Gotchas
- **Revert-prone area** (remote motion). Follow the workflow: grep named
decomp FIRST, pseudocode, port verbatim, ACE cross-check, harness, then
wire. No redesigns; the two prior #170 reverts were redesigns.
- The harness gotcha from #170: any new harness body MUST replicate the live
`RemoteMotion` construction (`InWorld=true`, `RemoteWeenie`,
Contact|OnWalkable|Active) or the TS-40 guard strips links and everything
wedges (`RemoteChaseDrainBisectTests` documents the shape).
- Per-entity probes must print the guid (memory:
`feedback_probe_identity_attribution` — the #170 misread).
- `StickyManager::adjust_offset` sign semantics: inside the gap the signed
distance goes NEGATIVE and the per-tick delta inverts (backs off) — ACE
StickyManager.cs:156, already noted on `MoveToMath.CylinderDistanceNoZ`.
- Do NOT arm ConstraintManager (#167 — two unknown x87 constants).
- Visual gate at the end: pack melee side-by-side vs retail; the user strafes
around the pack; attackers should reshuffle + keep facing like retail.
## Where things are (quick map)
- `MoveToManager.cs`: `PerformMovement:411` (Unstick head), `BeginNextNode`
sticky arrival ~700-714, `GetCurrentDistance` (UseSpheres cylinder math).
- `MoveToMath.cs`: `CylinderDistance`, `CylinderDistanceNoZ`,
`GlobalToLocalVec`, `NormalizeCheckSmall`.
- `GameWindow.cs`: `EnsureRemoteMotionBindings` ~4251 (seams + radius 0f pin),
`RouteServerMoveTo` ~4457 (MovementStruct — Radius/Height unset), remote
tick `TickAnimations` (PositionManager wiring points), player wiring ~13128.
- `ConstraintManager.cs` (R5-V1) — PositionManager sibling, reference for the
port style.
- Register rows: TS-39 (sticky seams), TS-35 (ConstraintManager), TS-41/42
(#170 rows, adjacent machinery).

View file

@ -0,0 +1,61 @@
# Pickup prompt — R5-V5: MovementManager facade (paste into a fresh session)
> **DONE 2026-07-05.** Implemented as specified; suite 4052 green with the
> protected suites unmodified. Arc close-out lives in
> `2026-07-03-r5-managers/r5-wiring-handoff.md` (top banner).
Read `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` first (the
§V4 status note + the facade paragraph are the SSOT), then
`docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md` (the
retail struct + method inventory is already decompiled — do NOT re-decompile).
Then implement R5-V5.
**Context:** the R5 arc is one slice from done. Shipped + user-gated
2026-07-04: V3 sticky melee (#171, three slices `5bd2b8bc`/`7a823176`/
`69966950` — TS-39 retired, TS-43/TS-44/AP-82 added) and the V4 behavioral
items (`f423884b` — head style-on-change for all movement types, #164
action-replay Autonomous bit, mt-0 wire flags 0x1 stick_to_object / 0x2
StandingLongJump). Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`.
**The slice (STRUCTURAL — zero behavior change):** retail gives every
entity ONE `MovementManager` (acclient.h; decomp 0x00524xxx) that owns the
`motion_interpreter` + `moveto_manager` the R4/R5 work ported. acdream
carries them as loose per-entity objects (`RemoteMotion.Motion`/`.MoveTo`,
`PlayerMovementController.Motion`/`.MoveTo`) wired by hand at three sites
(`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the harness). Build the
owner class in `src/AcDream.Core/Physics/Motion/MovementManager.cs`:
1. Owns MotionInterpreter + MoveToManager (lazy `MakeMoveToManager` per
retail 0x005245b3) + the relay methods whose call shapes are already in
the decomp doc: `UseTime` (minterp → moveto order), `HitGround` (minterp
FIRST then moveto — 0x00524300, already wired inline at 3 sites),
`HandleExitWorld`, `CancelMoveTo`, `HandleUpdateTarget` (→ moveto).
2. Repoint the three wiring sites to construct/hold ONE MovementManager per
entity; the existing seam bindings (sinks, StickTo/Unstick,
UnstickFromObject, PositionManager handoff) move onto/through it
unchanged. GameWindow keeps the wire unpack (Core.Net types stay out of
Core.Physics).
3. Do NOT reshuffle the per-tick order (TS-42's documented inversion is R6
scope, not this slice), do NOT touch the #170/#171 gate-passed machinery,
do NOT absorb RouteServerMoveTo/the routing heads into Core.
**Acceptance:** `dotnet build` + full `dotnet test` green with the
183-case/funnel/moveto/chase/sticky suites UNMODIFIED (a structural slice
that needs a test edit is a red flag — stop and reassess). Register: no new
rows expected; update row source columns that name the old wiring shape.
No visual gate needed (zero behavior change) — but launch once and confirm
a creature still chases + a door still opens before calling it done.
**After this slice:** the R5 arc is DONE — update the roadmap/milestones
line, write the arc close-out in the wiring handoff, then return to M1.5's
critical path (#137 dungeon collision, #138 teleport-OUT, A7 lighting).
**Gotchas:** revert-prone area — structural moves only, no "while I'm
here" improvements; the three wiring sites are load-bearing and
comment-dense (keep the retail-anchor comments with the code they
describe); the harness (`RemoteChaseEndToEndHarnessTests`) mirrors
GameWindow's wiring field-for-field — update its construction to the
facade in the SAME commit or the mirror lies; PowerShell Tee logs are
UTF-16 (`tr -d '\000'` before grep); the user manages client lifecycle
(graceful close; if a rebuild is locked by a running client, ask).

View file

@ -0,0 +1,236 @@
# CCylSphere collision family — retail pseudocode (port prep)
**Date:** 2026-07-05 · **Trigger:** the Holtburg town-network portal platform
(stab `0xC0A9B465`, Setup `0x020019E3`, CylSphere r=2.597 m h=0.256 m) blocks
the player with an endless rim slide instead of the retail step-up-onto-top.
Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less stab
CylSpheres — the collision SHAPE is right; the RESPONSE family was never
ported. Feeds #137 (dungeon door feet flow through the same dispatcher).
**Sources:** named-retail pseudo-C (addresses below) = ground truth;
`references/ACE/Source/ACE.Server/Physics/CylSphere.cs` = cross-reference
(settles BN x87 garbles; one ACE bug found, noted in §8).
## Retail function inventory
| Function | Address | pseudo-C line |
|---|---|---|
| `CCylSphere::intersects_sphere(CTransition*)` — dispatcher | `0x0053b440` | :324558 |
| `CCylSphere::intersects_sphere(Position*, float scale, CTransition*)` — wrapper | `0x0053b8f0` | :324744 |
| `CCylSphere::collides_with_sphere` | `0x0053a880` | :323943 |
| `CCylSphere::normal_of_collision` | `0x0053ab50` | :324102 |
| `CCylSphere::collide_with_point` | `0x0053acb0` | :324173 |
| `CCylSphere::slide_sphere` | `0x0053b2a0` | :324502 |
| `CCylSphere::step_sphere_up` | `0x0053b310` | :324516 |
| `CCylSphere::land_on_cylinder` | `0x0053b3d0` | :324542 |
| `CCylSphere::step_sphere_down` | `0x0053a9b0` | :324032 |
| `COLLISIONINFO::set_contact_plane(plane, is_water)` | `0x00509d80` | :271925 |
## 1. Wrapper (0x0053b8f0) — globalize the cylinder
```
intersects_sphere(cyl, Position* objPos, float scale, CTransition* t):
SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f)
world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale),
radius: cyl.radius * scale,
height: cyl.height * scale }
return world_cyl.intersects_sphere(t) // axis stays world-Z
```
**acdream mapping:** `ShadowEntry` already stores the globalized base point
(`Position` = entity pos + rotated scaled local offset, registration sites in
`GameWindow.cs`) and pre-scaled Radius/CylHeight — the wrapper's work is done
at registration. `cache_localspace_sphere` matters only for
`localspace_pos` (used by step_sphere_up's normal rotation, §6).
## 2. collides_with_sphere (0x0053a880) — pure overlap test
```
collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum):
// disp = sphere.center cyl.low_pt (caller computes)
if (disp.x² + disp.y² <= radsum²) // XY overlap
halfH = cyl.height * 0.5
if (|halfH disp.z| <= sphere.radius F_EPSILON + halfH) // Z band
return 1
return 0
```
`radsum` at every call site = `cyl.radius F_EPSILON + sphere.radius`
(ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting
exactly on the top" a non-overlap, so landings settle instead of re-colliding.
## 3. Dispatcher (0x0053b440)
```
intersects_sphere(cyl, CTransition* t): // cyl in world frame
sp = t.sphere_path; oi = t.object_info
s0 = sp.global_sphere[0]; disp0 = s0.center low_pt
if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center low_pt
radsum = cyl.radius F_EPSILON + s0.radius
// ── branch 1: placement / ethereal — detection only ──
if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 2: step-down probe — land on the top ──
if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum)
// ── branch 3: walkable probe — cylinder occupancy blocks ──
if (sp.check_walkable):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 4: normal sweep (collide flag clear) ──
if (!sp.collide):
if (oi.state & (CONTACT|ON_WALKABLE)): // grounded
if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ slide_sphere(t, s1, disp1, radsum, sphereNum=1) // §8: retail passes disp1
elif (oi.state & PATH_CLIPPED):
if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0)
else: // airborne
if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ collide_with_point(t, s1, disp1, radsum, 1)
return OK
// ── branch 5: collide-flag re-test — exact-TOI cap landing ──
if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)):
movement = sp.global_curr_center[0] s0.center block_offset(cur→check)
if |movement.z| < F_EPSILON COLLIDED
timecheck = (height + s0.radius disp0.z) / movement.z
offset = movement * timecheck
if radsum² < |xy(offset + disp0)|² → OK // rewound off the cap
t2 = (1 timecheck) * sp.walk_interp
if t2 >= sp.walk_interp || t2 < 0.1 → COLLIDED
pt = s0.center + offset; pt.z = s0.radius
ci.set_contact_plane(Plane(n=(0,0,1), d=pt.z), is_water=1) // literal 1, §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = t2
sp.add_offset_to_check_pos(offset)
return ADJUSTED
return OK
```
State bits (verified against our `ObjectInfoState`): CONTACT=0x1,
ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40.
## 4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe
```
step_sphere_down(t, s0, disp0, radsum):
if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK
stepScale = sp.step_down_amt * sp.walk_interp
if |stepScale| < F_EPSILON COLLIDED
deltaz = height + s0.radius disp0.z // lift so bottom rests on top
interp = (1 deltaz / stepScale) * sp.walk_interp // divisor = stepScale (BN garbled; ACE)
if interp >= sp.walk_interp || interp < 0.1 → COLLIDED
contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz s0.radius)
ci.set_contact_plane(Plane(n=(0,0,1), d=contactPt.z), is_water=1) // §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = interp
sp.add_offset_to_check_pos((0,0,deltaz))
return ADJUSTED
```
This is THE missing piece that made step-up-onto-a-wide-cylinder impossible:
`CTransition::step_up`'s internal step-down probe needs branch 2 to produce a
walkable contact plane ON the cylinder top.
## 5. normal_of_collision (0x0053ab50)
```
normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite:
dispCurr = sp.global_curr_center[sphereNum] low_pt
if (radsum² < dispCurr.x² + dispCurr.y²): // curr was XY-OUTSIDE side hit
n = (dispCurr.x, dispCurr.y, 0) // radial, horizontal
// definite unless the contact could actually be a diagonal cap hit:
zBandOverlapAtCurr = |halfH dispCurr.z| <= sphere.radius F_EPSILON + halfH
noZMovement = |dispCurr.z dispCheck.z| <= F_EPSILON
return zBandOverlapAtCurr || noZMovement
// curr was XY-INSIDE the footprint → cap hit
n = (0, 0, (dispCheck.z dispCurr.z <= 0) ? +1 : 1) // descending → top (+1)
return true
```
Cap polarity settled by ACE + geometry (BN's x87 branch rendering is
untrustworthy here — [[feedback_bn_decomp_field_names]] class 2).
## 6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0)
```
step_sphere_up(t, s0, disp0, radsum):
if (oi.step_up_height < s0.radius + height disp0.z) // too tall
→ slide_sphere(t, s0, disp0, radsum, 0)
definite = normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
nWorld = localspace_pos.localtoglobalvec(n) // rotate by the OBJECT's frame
if CTransition::step_up(t, nWorld) → OK
else → sp.step_up_slide(t)
land_on_cylinder(t, s0, disp0, radsum): // airborne foot hit
normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
sp.set_collide(n) // backup + Collide flag
sp.walkable_allowance = LANDING_Z (0.0871557)
return ADJUSTED
slide_sphere(t, sphere, disp, radsum, sphereNum):
normal_of_collision(..., sphereNum, out n)
if normalize_check_small(n) → COLLIDED
return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum])
```
The airborne landing closes through the retry loop: land_on_cylinder
(ADJUSTED, sets `sp.collide`) → next attempt → branch 5 exact-TOI rests the
sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK →
TransitionalInsert Phase 3 `sp.Collide` placement re-test validates on the
CP → landing completes.
## 7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits
Port per ACE `CylSphere.CollideWithPoint` verbatim (self-contained TOI math):
non-PerfectClip movers → `set_collision_normal` + COLLIDED. PerfectClip →
exact time-of-impact reposition (`add_offset_to_check_pos`) + ADJUSTED, with
the not-definite branch deriving cap-vs-side from the movement.
## 8. Divergences + settled ambiguities (register-relevant)
1. **`is_water=1` on cylinder-top contact planes is RETAIL** (literal 1 at
0x0053aae2 and the branch-5 site; `set_contact_plane` 0x00509d80 stores
arg3 → `contact_plane_is_water`). Port verbatim; do not "fix".
2. **ACE bug (do NOT copy):** ACE's grounded head-sphere leg passes the FOOT
disp to `SlideSphere`; retail 0x0053b843 passes the HEAD disp (`x_2`).
Retail wins. (Class: [[feedback_bn_decomp_field_names]] #3 — ACE decode
wrong in a branch ACE rarely exercises.)
3. **Block offset in branch 5:** retail subtracts the cur→check landblock
offset; acdream's physics frame is continuous world-space → offset = 0.
Standing frame adaptation (same as SlideSphere's gDelta note).
4. **Ethereal targets:** branch 1 returns COLLIDED on overlap even for
ethereal; passability comes from the caller's Layer-2 override
(pc:276961-276989, non-static + !step_down → forced OK) plus the #150
step-down skip. The previous port consumed ObstructionEthereal with an
early OK before any test — response-equivalent for non-static targets,
but branch 1 is the faithful shape and also gives placement inserts the
retail blocked-by-cylinder semantics. Ported faithfully now.
5. **`normalize_check_small`** = normalize; returns true (fail) when |v| < ε
before normalizing — maps to `LengthSquared() < EpsilonSq` guard.
6. **step_sphere_up normal rotation:** retail rotates the collision normal by
the target OBJECT's frame (`localspace_pos` = the object's Position cached
by the wrapper) before `CTransition::step_up`. For yaw-only AC objects
this only affects yawed radial normals; ported faithfully via
`Vector3.Transform(n, obj.Rotation)`.
## 9. acdream port surface
`Transition.CylinderCollision` (TransitionTypes.cs) becomes the branch-4/5
dispatcher body; new private siblings `CylCollidesWithSphere`,
`CylNormalOfCollision`, `CylStepSphereUp`, `CylStepSphereDown`,
`CylSlideSphere`, `CylLandOnCylinder`, `CylCollideWithPoint`. Callers
unchanged (`FindObjCollisionsInCell` Cylinder branch; the BspOnlyDispatch
gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are
unaffected). `DoStepUp` (= CTransition::step_up, A6.P6) and
`SpherePath.StepUpSlide` are reused as-is.

View file

@ -0,0 +1,56 @@
# Pickup prompt — M1.5 dungeon track: #137 collision first (paste into a fresh session)
Read `claude-memory/project_physics_collision_digest.md` FIRST (the collision
SSOT + DO-NOT-RETRY table — binding), then the ISSUES entries for **#137**,
**#153**, **#138** (in that order), plus the prior-art closed fixes **#150**
(ethereal door step-down skip), **#151** (portal-less wall shells), **#152**
(building-cache re-base). Then work the track below.
**Context:** the R5 movement-manager arc is DONE and merged — `main` ==
`claude/vigorous-joliot-f0c3ad` @ `aa734f34` (facade close-out:
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`). M1.5's remaining
critical path is the dungeon track: **#137 dungeon collision → #153/#138
teleport-out residuals → A7 dungeon lighting** (A7 last — its 5-dungeon-site
capture protocol needs dungeons walkable first).
**Slice 1 — #137 (dungeon collision at doors + wall openings), oracle-first:**
1. AUTONOMOUS PREP: wire nothing yet. Re-read the EnvCell collision path
(`CellTransit`, the door apparatus, `ShadowObjectRegistry` per-cell
registration — A6.P4 architecture) and check whether the #150 ethereal
step-down skip (`TransitionTypes.cs`, retail pc:276795-276806) already
covers EnvCell dungeon doors or only building doors. Grep named-retail
before any fresh decompilation.
2. REPRO (needs the user in-world): the 0x0007 dungeon, with
`ACDREAM_PROBE_RESOLVE=1` (+ `ACDREAM_CAPTURE_RESOLVE=<path>` if a replay
diff is warranted). Characterize per site: which door / which opening,
expected vs actual, open-vs-closed state. The issue text says symptoms are
NOT yet characterized — do not fix ahead of the repro.
3. Fix per the digest workflow (retail decomp is the oracle; ACE second).
Acceptance: doors block/pass per open/closed state, wall openings pass,
solid walls block — matching retail in 0x0007. Register row per deviation,
same commit.
**Slice 2 — #153 (portals only work once / teleport-onto-unstreamed-edge
runaway), apparatus-first:** the issue is REOPENED with a narrowed residual
(arrival on a NOT-yet-streamed landblock NEAR AN EDGE → cell-march + Z
free-fall + garbage outbound cell). The issue text itself says **DO NOT
guess-patch** — the original #145 burned 5 attempts. Build the capture +
anchor/guard diagnostic at the crossing first. ⚠️ The issue's "likely fix
shape" is a streaming-gap HOLD — but the user REVERTED a previous teleport
hold as "shaky and bandaid" (`feedback_no_holds_for_slow_foundation`). If the
evidence really does point at a hold (as retail's synchronous-load
equivalent), STOP and get explicit user approval with the retail anchor in
hand; do not ship it on your own authority.
**Then:** the #138 acceptance run (portal out → world + objects + avatar all
present across REPEATED round-trips, collision working — its component fixes
are shipped; #151/#152 closed the wall residuals; what's left is the gate),
and A7 lighting (#154 dungeon-dim, #142 windowed interiors, #143 portal
swirl, #140 Fix D) as the M1.5 closer.
**Gotchas:** visual gates + repro sessions need the user (plan autonomous
decomp/probe prep around their availability); PowerShell Tee logs are UTF-16
(`tr -d '\000'` before grep); the user manages client lifecycle (plain
`dotnet run`, graceful close, ask if a rebuild is locked); probes:
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` / `ACDREAM_CAPTURE_RESOLVE` /
`ACDREAM_PROBE_ENT` are all wired and DebugPanel-toggleable.

View file

@ -0,0 +1,94 @@
# MP0 baseline — frame-profiler capture, attribution verdict, gate decision
**Date:** 2026-07-05. **Build:** Release, `ACDREAM_FRAME_PROF=1`, vsync off.
**Character:** `+Horan` (`0x5000000B`, testaccount2). **Logs:** `mp0-baseline.log`
(primary run: full route with 4 teleports, ~2 min) + `mp0-baseline-run3.log`
(supplemental: longer town session, same build) — both untracked in the worktree
root; the numbers below are transcribed from them.
**Route (reconstructed from log markers):** login → Holtburg outdoor
(`0xA9B4`) → movement/turning → teleport 1 → **dungeon `0x0007`** (dungeon
collapse streaming path) → walk → teleport 2 → **outdoor town `0xCE94`**
pan/walk → teleport 3 → **dungeon again** → walk → teleport 4 → Holtburg →
close (graceful).
## The numbers (per 5-second `[frame-prof]` window)
| State | cpu_ms p50 | p95 | p99 | max | gpu_ms p50 | alloc/frame p50 | GC gen0/gen1/gen2 per 5 s |
|---|---|---|---|---|---|---|---|
| Login settle (Holtburg) | 2.1 | 10.2 | **52.8** | **123.3** | 0.4 | 1.57 MB | 93 / **50** / **36** |
| Holtburg steady/turning | 1.62.0 | 2.22.6 | 2.83.7 | 1520 | 0.4 | 1.581.60 MB | ~80 / 313 / 35 |
| Dungeon `0x0007` walk | **0.40.5** | 0.50.6 | 0.9 | 1.21.5 | 0.1 | 0.500.63 MB | 119 / **0** / **0** |
| Town `0xCE94` pan/walk | 2.93.6 | 4.05.0 | 7.215.7 | 2637 | 1.21.6 | 2.53.0 MB | ~60 / 522 / 511 |
| Teleport→dungeon window | 2.8 | 3.3 | 14.1 | **211.2** | 1.3 | (max **75.7 MB** in one frame) | 62 / 11 / 10 |
| Teleport→outdoor windows | 1.22.9 | 3.43.8 | 4.57.0 | 33 | 0.21.5 | (max 2031 MB single frame) | ~8093 / 1526 / 5 |
| Holtburg end-of-run moving | 2.62.9 | 3.35.1 | 8.5**23.0** | 20**87** | 2.52.7 | 2.14 MB | ~5058 / 2432 / 78 |
| (run 3) town views | 1.72.4 | 2.73.4 | 7.915.8 | 2939 | 0.42.2 | 1.72.2 MB | ~70109 / 1732 / 611 |
Stage attribution (`upd` / `upl` / `imgui`): **≈ 0.0 ms p50 in every window**
(occasional upd p95 0.11.7 during streaming). GPU never exceeded p95 3.7 ms.
## Attribution verdict
1. **Steady-state medians are far better than the spec §1 assumption.** The
spec assumed ~6 ms / ~165 FPS dense-town CPU. Captured: worst steady town
view is p50 3.33.6 ms (~280300 FPS); Holtburg ~1.62.4 ms (~400600 FPS);
dungeons 0.40.5 ms (~2000 FPS). **Caveat:** the canonical Fort
Tethana/Arwic "dense worst-case axiom view" was NOT in this route — the
§1 numbers came from there. The 300-FPS throughput goal is close to met in
the captured views but must be re-verified at that view before any
"target met" claim.
2. **The frame is CPU-render-side, as assumed.** GPU ≤ ~2.7 ms everywhere;
upd/upl/imgui ≈ 0 → virtually all CPU time is unattributed render-side
submission. The spec's MP3 thesis stands.
3. **The smoothness gap is real, and it is GC.** Every town window violates
the ≤16 ms goal at max (2087 ms) and several at p99 (up to 23 ms), while
dungeon windows — the ONLY windows with **zero gen1/gen2 collections**
are spike-free (max 1.5 ms). Steady-state allocation is **1.53.0 MB per
frame** (≈ 0.61.2 GB/s at these frame rates), driving gen2 collections
roughly 12 per second in towns. The correlation is airtight across all
28 windows: spikes appear exactly where gen1/gen2 counts are non-zero.
4. **The hitch scenario is confirmed and quantified.** Teleport-into-dungeon
produced the worst frame of the session (**211 ms**) with a **75.7 MB**
single-frame allocation (decode + hydrate storm); login settle hit 123 ms
max with 36 gen2 collections in one window. This is exactly the class MP1
(bake — load becomes mmap reads, not managed decode) targets.
## Gate decision (spec §5)
**PROCEED to MP1, with one amendment.** The assumed cost split (render-side
CPU dominant, GPU idle, hitches at load boundaries) is CONFIRMED, so the
phase order stands. The amendment: the capture elevates **steady-state
allocation churn** from "MP4 cleanup" to a first-class smoothness lever —
1.53 MB/frame of per-frame garbage is the direct cause of the town p99/max
violations, and it is NOT load-related (it persists in steady windows).
**Amendment (recorded in the spec):** after MP1 ships, run a bounded
**steady-state allocation triage** (one session: `dotnet-trace` /
allocation-sampling on the live client, identify the top ~5 per-frame churn
sites). Sites *inside* the MP3 rewrite surface (per-frame submission
rebuild — the prime suspect for MB-scale per-frame lists) are left for MP3
to remove structurally; sites *outside* it get targeted fixes immediately.
The full zero-alloc pass remains MP4. This front-loads the biggest
smoothness win without letting "fix allocations" sprawl into an unphased
rewrite.
## Follow-ups noted (not gates)
- Re-measure the Fort Tethana axiom view at the MP2 and MP3 gates (the
degrade port materially changes exactly that view).
- `upd ≈ 0.0` everywhere is suspicious-adjacent but plausible (light sim in
these scenes); if a future capture shows heavy OnUpdate work still reading
0.0, verify the stage scope wasn't bypassed by an early return upstream.
- Dungeon at ~2000 FPS with zero GC pressure is the existence proof that the
engine's frame loop CAN run allocation-free at four-digit FPS — towns are
paying for churn, not for fundamentals.
## Apparatus state
`[frame-prof]` (env `ACDREAM_FRAME_PROF=1`, DebugPanel "Frame profiler"
checkbox) is PERMANENT — it is the measurement instrument for every MP gate.
Implementation: `src/AcDream.App/Diagnostics/` (FrameStatsBuffer /
GpuFrameTimer / FrameProfiler), plan
`docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md`, commits
`7d74c68c`..`4b44a152`.

View file

@ -0,0 +1,92 @@
# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session)
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A —
one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176
and #177**, then this file. The physics digest
(`claude-memory/project_physics_collision_digest.md`) carries the full
2026-07-06 collision saga if background is needed — do NOT reopen it.
## Where we are (2026-07-06 end of session)
**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not
collision anymore. Good." / "Looks good"). Branch
`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`,
NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385).
| Commit | Fix |
|---|---|
| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). |
| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. |
| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. |
| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. |
**#137 stays OPEN for the DOORS half only** (block/pass per open state).
The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it
whenever the user is next at the hub double door (closed blocks AT the
visual panels from both sides, no embed, no phantom wall).
## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session)
- **#176 — purple flashing on dungeon floors at cell seams, camera-angle
dependent.** Survives all physics fixes → render-side. Magenta/purple =
the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]).
- **#177 — stairs between levels pop in/out.** Invisible from the corridor
looking into the stair room, appear on entering, vanish on the last step
running down. The #119 visibility class, dungeon edition. Anchor cells:
the transit `0x8A020182 → 0x8A020183` drops z 6 → 9 on stairs
(launch-137-gate2.log).
**The load-bearing topology fact both issues share (discovered this
session):** Facility Hub corridor FLOORS are portal polygons — PortalSide
floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E,
horizontal at z=6, spanning the whole floor; 0x011E is a hall at z=12).
Level connections run through these floor-portals. "Purple at the seams" is
purple exactly where portal surfaces meet, and the stairs' rooms hang off
the same portal graph — suspect the render portal-flood/portal-surface
handling of HORIZONTAL portals.
**⚠️ The id-space trap (cost this saga a wrong mechanism):**
`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`),
NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons.
## Tooling built this session (reuse, don't rebuild)
- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add
`InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`),
full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf
membership, hit-normal candidate sweep (use |align| — winding flips),
`HumanSetup_CollisionSpheres_DatTruth`.
- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor
harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3
cells are invisible to the flood — why clean-room replays kept passing).
In-test probe capture pattern: `Console.SetOut(StringWriter)` +
`PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live.
- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'`
before grep): `launch-137-seam-probes.log` (790 MB, step-level),
`launch-137-gate2.log`, `launch-137-gate3.log`,
`resolve-137-seam-capture.jsonl` (body snapshots, untracked).
## Physics DO-NOT-RETRY highlights from today (full table in the digest)
- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns
Collided; failed transitions never write body sliding state.
- The absorbed exactly-anti-parallel frame against a persisted sliding
normal is RETAIL behavior — fix normal PROVENANCE, not the abort.
- No height-budget check in the step-down accept — retail's climb cap is
`adjust_sphere_to_plane`'s walk_interp 0.5 overshoot bound (0x00538210)
+ the placement insert rejecting the HEAD in solids.
- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=`
= NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a
logger, not the decision-maker.
- Remaining registered leaks (rows exist, fix later): TS-45
(`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent),
TS-46 (scalar sphere approximation; remotes use human dims).
## Launch protocol (unchanged)
Build green first; PowerShell launch with the env block from CLAUDE.md
(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background +
Tee to `launch-*.log`. The user manages client lifecycle. Graceful close →
ACE session clears in ~5 s; hard kill → ~3 min. The test character may be
saved in odd places after collision testing (last session it was inside the
window alcove and ACE bounced it to Holtburg — the user portals back).

View file

@ -0,0 +1,87 @@
# Pickup prompt — #137 corridor phantom collision (paste into a fresh session)
> **SUPERSEDED 2026-07-06.** The corridor phantom is FIXED (visual gate
> pending) — see `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`.
> Mechanism 2 was real (BSPQuery stub slide responses leaked sliding
> normals; fixed). Mechanism 1's framing was WRONG: the recorded wall
> normal was SYNTHETIC (slide_sphere's opposing branch + a `return OK` vs
> retail's COLLIDED_TS misport — fixed); the PortalSide polys are ±Y
> planes perpendicular to the run, directionally culled, tested by
> retail's own BSP leaves too, and plausibly legitimately solid
> (window/grate class). The step 1 cdb session below is NOT needed for
> this repro. Kept for the audit trail only.
Read `claude-memory/project_physics_collision_digest.md` FIRST (binding
DO-NOT-RETRY table), then **ISSUES #137** (the 2026-07-05 CHARACTERIZED
section — the full evidence chain lives there), then this file. The 2026-07-05
session's ledger for context: #172 (CCylSphere family port), #173 (remote
ceiling bounce), #174 (motion-queue drain — doors work after jumping), #175
(door collision at the motion-table closed pose, two takes).
**The bug (user-verified repro, Facility Hub 0x8A02):** running down a
corridor, an INVISIBLE blocker stops the player mid-corridor; the player can
walk around it. Two stacked mechanisms, both evidence-pinned:
## Mechanism 1 — PortalSide portal polygons are solid for us
- Live: crossing corridor cells `0x8A02016E → 0x8A02017A` at world x≈85.25
recorded ONE wall hit, normal (1,0,0) — straight against the movement
(`launch-175-verify2.log:42858`, worktree root).
- Dat (`Issue137CorridorSeamInspectionTests`, committed): cell 0x8A02016E's
three portals to 0x011E (polys 1/3/5, flags=**PortalSide**, NOT ExactMatch)
are PRESENT in `CellStruct.PhysicsPolygons`; every ExactMatch portal in the
same cell is absent from the physics set. The cell's rotation maps those
portal planes into the world X wall the player hit.
- Oracle greps DONE (do not repeat): `CCellStruct::UnPack` 0x00533d00 loads
physics polys + BSP verbatim (no portal stripping);
`CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure`
(0x005394f0/0x00539540/0x00538a10) are pure geometry;
`CCellPortal` (0x0053bab0) carries portal→CPolygon + portal_side +
exact_match but the BSP test chain never consults it.
- **NEXT (step 1 protocol, needs the user):** cdb-attach a live retail
client at this exact corridor (Facility Hub, the 016E↔011E portals) and
trace which path lets retail through: breakpoint
`BSPTREE::find_collisions` / `BSPLEAF::sphere_intersects_poly` /
`CPolygon::pos_hits_sphere` and see whether the portal polys are ever
TESTED (candidate A: sidedness/stippling — the polys carry stip=NoPos —
or the pos_hits_sphere tail's directional cull) or never REACHED
(candidate B: transit/membership order hands the sphere to the neighbor
cell whose geometry has a real hole). Toolchain crib:
`claude-memory/project_retail_debugger.md` + the CLAUDE.md
"Retail debugger toolchain" section. Verify the binary with
`py tools/pdb-extract/check_exe_pdb.py` first.
- ⚠️ Do NOT ship a "skip portal polys in the physics BSP" filter on
assumption — if retail's answer is sidedness or test order, a blanket
skip opens holes (some PortalSide polys may be legitimately solid from
one side — one-way drops etc.).
## Mechanism 2 — the sliding-normal absorbing wedge (fix independently)
- After the single seam hit, EVERY forward resolve returns `ok=False
hit=no` with zero advance: the body-persisted SlidingNormal (1,0,0)
projects the +X offset to exactly ZERO in `Transition.AdjustOffset`, and
the stepping loop's abort-small-offset fires at step 0 (TransitionTypes
`FindValidPosition` loop, `return i != 0 && …`) — BEFORE any collision
test could refresh the state. An ABSORBING wedge; strafing escapes it
(the user's "push through on the side").
- Retail re-derives slide state per frame — `OBJECTINFO::get_object_info`
pc:279992 "governs only the NEXT frame" (#116 notes in the digest).
AUDIT: who writes the body's persisted SlidingNormal
(PhysicsEngine.ResolveWithTransition seed ~:995-1040 + the writeback),
and where retail CLEARS it when contact does not recur. This is the #116
slide-response family — check ISSUES #116 before changing anything
(oracle-first; the digest's DO-NOT-RETRY table applies).
- Likely the bigger playability win: without the wedge, mechanism 1 alone
would be a momentary stutter, not a dead stop.
**Order:** mechanism 2 first (pure acdream-side audit + fix, testable with a
replay-style unit test: seed a body with a stale sliding normal, resolve
forward with no obstruction in range, assert the step is NOT zeroed), then
the mechanism-1 cdb session when the user can run retail side-by-side.
**Gotchas:** PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep);
the user manages client lifecycle; probes RESOLVE/CELL/BUILDING are
DebugPanel-toggleable (ACDREAM_DEVTOOLS=1); the [shape-pose] line
(ACDREAM_DUMP_MOTION=1) prints each BSP registration's pose source.
Register rows to touch if fixes ship: none exist yet for either mechanism —
add per the same-commit rule; #116's row interactions per the digest.

View file

@ -0,0 +1,115 @@
# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06)
The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked:
*who writes the body's persisted SlidingNormal, and where does retail CLEAR
it when contact does not recur?* This note is the complete decomp-verified
answer, the divergence map it produced, and the fix that shipped.
## Retail lifecycle (every site, named-retail-verified)
`collision_info.sliding_normal` (per-transition) and
`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4,
body-persisted) form a two-level cache:
| Step | Function | Address / pc line | What it does |
|---|---|---|---|
| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition |
| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset = n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` |
| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing |
| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean |
| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)`**the ONLY in-transition writer**. Fires when a step needed collision handling |
| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state |
| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) |
So the answer to "where does retail clear it": **the success writeback**
(bit 4 syncs to the transition's final `sliding_normal_valid`, which the
per-step clear leaves false unless the last step's validate re-recorded a
collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED
transition nothing clears it — and nothing needs to, because a persisted
normal can only have come from a validate write against real geometry
(pressed-at-a-wall is a correct absorbed state; any oblique input escapes
via the tangential projection remainder and the escape frame's writeback
clears the bit).
ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback
`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only
`SetSlidingNormal` call sites in ACE's whole physics tree.
## The wedge (live evidence, launch-175-verify2.log:42858)
The seam-hit frame **succeeded with full advance** (`ok=True`,
`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A)
and still recorded `hit=yes n=(1.00,0.03,0.03)`. Retail ending that frame
would write back `sliding_normal_valid=0` (no blocked step at the end → the
per-step clear wins) and the bit would CLEAR. We persisted a normal anyway —
because our BSP Contact branch carried **stub** slide responses
(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where
retail dispatches the real `slide_sphere`. Every following forward resolve
then seeded the stale normal, `adjust_offset` projected the
exactly-anti-parallel corridor push to zero, and the step-0 abort returned
`ok=False hit=no` with zero advance — before any collision test could
refresh the state. An absorbing wedge; strafing escapes because an oblique
offset keeps a tangential remainder.
## Divergence map → what shipped
| Site | Was | Retail | Action |
|---|---|---|---|
| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide``CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) |
| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper |
| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) |
| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up |
| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere``slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does |
| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged |
Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact
foot-fallback + head full-hit must not write the sliding normal; face-on
grounded → `Collided` per the degenerate crease projection) + the
engine-level wall lifecycle pin (persist-on-block via validate →
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385).
## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed
Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory
entirely; **no cdb session needed for this repro**:
1. **The recorded hit normal matches NO polygon.** A world-space sweep of
both seam cells + every portal-adjacent neighbor
(`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`)
found zero physics polygons within 18° of `(1.00,0.03,0.03)` near the
hit point. The normal is the player's **negated movement direction** — a
SYNTHETIC value from `slide_sphere`'s opposing-normals branch
(`reversed = gDelta``set_collision_normal`).
2. **The PortalSide polys were a red herring for this hit.** Cell
0x8A02016E has IDENTITY rotation (the prior session's "rotation maps
them into the X wall" was wrong); polys 1/3/5 are ±Y-normal planes at
world y≈38.33, 1.4 m beside the player's track and PERPENDICULAR to
the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled,
0x005394f0 tail) rejects them for that movement outright. They ARE
referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`),
so retail tests them too when approached INTO their plane — most likely
they are legitimately solid one-way/window-class geometry (which is why
the dat keeps PortalSide-only portal polys in the physics set while
removing every ExactMatch one). The pickup's warning against a blanket
"skip portal polys" filter stands — no filter is needed at all.
3. **A second slide_sphere port bug found and fixed:** the opposing-normals
branch returned OK where retail returns COLLIDED_TS
(0x005375d7-0x0053762c: `*normal = gDelta; normalize;
set_collision_normal; return 2`). Our OK let the step complete as-is
while carrying the synthetic reversed-movement collision normal —
`validate_transition`'s epilogue then converted it into the sliding
normal the wedge absorbed on. Fixed at the same TransitionTypes site;
pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`.
4. **The dat-backed corridor replay reproduces the live frame and runs
clean** (`Issue137CorridorSeamReplayTests`): same input, same full
advance to (85.253, 39.776, 5.992), same 016E→017A transit — now
`hit=no`, no sliding normal persisted, and six further forward frames
advance freely. (The pre-fix code did NOT reproduce the wedge in the
replay — the live entry chain involved session state beyond the
replay's reach — so the replay is the CLEAN-corridor pin, not a
red/green falsification; the site-level pins in
`Issue137SlidingNormalLifecycleTests` are the red→green proof.)
Remaining for #137: the user's corridor re-run (visual gate) + the issue's
door half (doors block/pass per open state — separate acceptance).

View file

@ -0,0 +1,152 @@
# Pickup prompt — #176/#177 root-caused, deferred to A7 dungeon lighting (paste into a fresh session)
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (top banner
is the #176/#177 outcome + DO-NOT-RETRY), then **ISSUES #176 and #177**, then
this file. Then read **roadmap Phase A7** (`docs/plans/2026-04-11-roadmap.md`
§"Phase A7 — Indoor lighting fidelity") — this session effectively pre-paid
A7's analysis for the light-cap slice.
## Where we are (2026-07-06 end of session)
**Currently working toward: M1.5 — Indoor world feels right.** Critical path:
#137 dungeon collision (DONE, gated), #138 teleport-OUT, **A7 dungeon lighting
(#79/#93 + now #176/#177)**.
HEAD = `d591e3bb` on `main` AND branch `claude/vigorous-joliot-f0c3ad`
(fast-forwarded, in sync). Working tree clean. All suites green
(Core 2591 + 3 skip / App 719 + 2 skip / UI 425 / Net 385).
Three commits this session:
| Commit | What |
|---|---|
| `b8e9e204` | #176/#177 investigation: 12 mechanisms refuted, apparatus shipped, probe protocol staged |
| `4d25e04d` | fix attempt: `MaxGlobalLights` 128→1024 (stops the pops) — **REVERTED** |
| `d591e3bb` | revert to 128 + full deferral docs (register AP-85 rewritten, ISSUES back to OPEN, digest DO-NOT-RETRY) |
A client may still be running (`launch-176-revert-check.log`) — the user
manages lifecycle. It is on the reverted 128 baseline (rooms normal, seam
flashes present = the still-open issue).
## THE ROOT CAUSE (confirmed, not a hypothesis)
**#176 and #177 are ONE bug: per-cell 8-light SET COMPOSITION churning under a
camera-nearest snapshot cap.** `LightManager.BuildPointLightSnapshot` keeps only
the `MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub
registers **366** fixtures, so 238 are evicted per frame by camera distance.
`SelectForObject` (the faithful per-object 8-cap, retail
`minimize_object_lighting` 0x0054d480) can only choose from the surviving 128 —
so an in-range torch of a VISIBLE cell that ranks past the cap drops out of that
cell's 8-set, and the cell's per-vertex Gouraud lighting flips as the camera
moves.
- **#176** — the flipping unit is a CELL → discontinuities at exactly cell-seam
granularity; camera-angle dependent (the chase boom swings the camera position,
re-ranking the 128); the dominant flipping light is the under-room PORTALS'
purple → purple flashes on the floor at seams.
- **#177** — a stair room whose fixtures ALL rank past the cap renders at bare
0.2 ambient (near-black = "not visible from the corridor"); approach
re-admits them ("pops into existence"); the eviction boundary sweeping during
the descent strips the ramp's lights ("disappears on the last step"). **The
geometry never vanished — its LIGHTS did.**
**How it was confirmed (the discriminator):** the user reproduced the flash
while `[light]` (ambient branch — stable 0.2 grey) AND `[pv-input]` (portal
flood — zero drops in 54k frames) read provably healthy in the probe log. That
eliminated every CPU signal the probes COULD see and left the one they can't:
set composition (`[light]` prints counts, not membership). The log's headline
number — `registeredLights=366` vs cap 128 — closed it.
## WHY THE FIX IS DEFERRED (do not re-raise the cap alone)
Uncapping (128→1024, `4d25e04d`) stopped the pops but the full 366-fixture pool
exposed three UNPORTED retail lighting semantics that then dominated the Hub —
this is why it was reverted (user: "rooms have no textures" → actually a magenta
light wash over intact textures; then "purple stripes… something fighting to
draw the purple lightning over the floor"):
1. **Light-through-solid-floors.** Retail registers lights per-CELL
(`insert_light` 0x0054d1b0); a light belongs to a cell and only lights that
cell's geometry. Our snapshot is a flat world-space sphere-overlap with NO
reach/occlusion notion → the under-room portals' purple light washed the
corridors ABOVE them. **This is the big one.** The 128 cap accidentally
MASKED it by keeping the pool camera-local (far under-room lights fell off
the list before they could reach up).
2. **Fixture falloff curve misassignment.** Stationary weenie fixtures
(ACE serves dungeon lanterns/braziers as CreateObject weenies) register via
the `isDynamic:true` path → D3D 1/d falloff (`LightInfoLoader.cs:89`,
GameWindow weenie-light block ~3688). Retail bakes STATIONARY fixture light
with the static 1/d³ curve (`calc_point_light` 0x0059c8b0, static_light_factor
1.3). 1/d is ~9× stronger at 3 m → every pool over-broad + over-saturated.
The `isDynamic` flag should be reserved for genuinely MOVING lights (portal
swirls, projectiles); a stationary fixture — even server-spawned — is static.
3. **Striped floor z-fight-like artifact.** User's 2nd screenshot: regular
magenta bands across one floor region, "like something is fighting to draw
the purple over the floor." **NOT attributed.** Ruled out: not coincident dat
geometry (the `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep
found only the legit z=12 under-hall floor quad-fan, nothing near the 6
corridor floor); not a striped texture (all corridor surfaces are plain
`Base1Image` stone 0x08000375/6/7/8). Leading guess: two draws of the same
floor with DIFFERENT light sets (the per-cell-vs-per-something set assignment
splitting), or an MDI instance-order/light-set-index desync exposed only when
the purple light is stably present. **Hunt this in A7 with the full pool
temporarily on** — it's invisible at cap 128.
## THE A7 FIX SHAPE (the real fix, in order)
1. **Port per-cell light registration** (`insert_light` 0x0054d1b0 + the
per-cell light list retail keeps). A light lights its OWN cell's geometry +
cells reachable through portals — NOT arbitrary world-space overlap. This
kills #1 (through-floor) and makes the global pool cap irrelevant (per-cell
sets are naturally bounded), which is what actually lets #176/#177 close.
2. **Static curve for stationary fixtures.** Decide `isDynamic` by whether the
light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
is stationary → static 1/d³. (Register AP-67/AP-44 are the weenie-light path;
AP-85 is the pool cap; #143 is the curve-by-path decision to revisit.)
3. **Hunt the stripes** with the full pool on (see #3 above).
4. **THEN uncap** `MaxGlobalLights` — un-skip
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
(it asserts the retail end-state: an in-range light of a cell is never
camera-evicted).
## Tooling built this session (reuse, don't rebuild)
- **`tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`**
— dat truth for the Hub: portal-poly draw verdicts, reciprocal coincidence,
stair geometry owner (`0x8A020182`'s ramp shell, vertical portals, ZERO
statics), CellBSP containment (partitions exactly at portal planes),
under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0
transparent texels), and `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
(the stripe-geometry sweep — came back empty for the 6 floor).
- **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`**
— production-matched portal-flood replays (approach/descent/gaze-sweep/walk +
the ScenarioE incoherent-root sensitivity pin). Flood is HEALTHY — do not
re-investigate it for these issues.
- **`tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`**
— resolver cell-flip is plane-exact (membership is NOT the bug).
- **`LightManagerTests.PointSnapshot_HubScaleLightCount_*`** — Skip'd
end-state pin (RED@128, GREEN@1024).
- **Ledger**: `docs/research/2026-07-06-176-177-render-pair-investigation.md`
(13 refuted mechanisms + the probe-run discriminator + the OUTCOME banner).
## Live probe env (all zero-cost off; use for the A7 spike)
```
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun/registeredLights/activeLights — rate-limited
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
ACDREAM_PROBE_TEXFLUSH=1 # [tex-flush] staged-upload drain (proves #105 healthy: after=0)
```
A7.L1's planned `[indoor-light]` probe (per-cell active-light dump: position,
color, attenuation, direction) is the natural next apparatus — it prints exactly
the SET COMPOSITION the current `[light]` counts can't. Build it FIRST.
## Launch protocol (unchanged)
`dotnet build` green first; PowerShell launch with the CLAUDE.md env block
(+ the probes above), background + Tee to `launch-*.log`. User manages client
lifecycle (graceful close → ACE clears in ~5 s; hard kill → ~3 min). Strip
`\000` before grep (PowerShell Tee = UTF-16): `tr -d '\000' < launch.log | grep ...`.
Test char spawns near `0x8A020179` (the ramp corridor); the 015E↔017A corridor
loop is the #176 repro; look-into + descend the 0178→0182→0183 stairs for #177.

View file

@ -0,0 +1,181 @@
# #176/#177 render pair — investigation ledger (2026-07-06, session 2)
## ✅ OUTCOME (same day, after the probe launch): ROOT CAUSE FOUND + FIX SHIPPED
**The probe run discriminated it.** The user reproduced the purple floor
flash while BOTH surviving CPU theories read provably healthy in the log —
`[light]` insideCell/ambient rock-stable (one pre-spawn outdoor line, then
flat 0.2 grey through 36 transits), `[pv-input]` flood stable (54k frames,
zero collapses). That eliminated T-A and T-B and exposed the one channel
the probes were structurally blind to: **per-cell 8-light SET COMPOSITION.**
The log's own headline number told the story: `registeredLights=366`
against `MaxGlobalLights = 128`. `BuildPointLightSnapshot` kept the 128
lights nearest THE CAMERA and evicted 238 every frame; `SelectForObject`
(camera-independent, faithfully retail — and unit-PINNED as such) could
only choose from the surviving 128. An in-range torch of a VISIBLE cell
that ranked past the cap dropped out of that cell's 8-set → the cell's
per-vertex Gouraud lighting flipped as the camera moved (the chase boom
swings the camera position by meters, re-ranking the 128):
- **#176:** the flipping unit is A CELL → discontinuity lines at exactly
cell-seam granularity; a torch-losing floor drops to dim blue-grey
stone (0.2 ambient × stone = the perceived purple); camera-angle
dependent by construction.
- **#177:** a stair room whose torches ALL ranked past the cap rendered
at bare 0.2 ambient — near-black in a dungeon = "not visible from the
corridor"; approaching re-admitted them = "pops into existence"; the
boundary sweeping during the descent dropped the ramp's lights =
"disappears on the last step". The geometry never vanished — its
LIGHTS did.
Retail anchor: `minimize_object_lighting` (0x0054d480) selects from the
cell-registered reaching set (`insert_light` 0x0054d1b0) — **no global
camera-nearest pool cap exists in retail.**
**Fix:** `MaxGlobalLights` 128 → 1024 (a non-biting safety valve; the
GPU packer grows to fit — 64 B/light). Register row **AP-85**. TDD pin:
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). All four
suites green. **Pending the user visual gate.**
Process note: the pre-existing test
`SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre` was written
to pin "the property that kills the lights-up-as-I-approach popping" — it
proved the SELECTOR camera-independent while the SNAPSHOT it selects from
was camera-capped. The pop re-entered one stage upstream of the pin.
---
**Pre-launch status below (kept as the audit trail): mechanism NOT yet pinned — but the hypothesis space is now razor-thin.**
Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless
replays, production-log analysis, code reads). Every layer that can be checked
offline is verified HEALTHY at the anchor cells. The surviving discriminator
requires ONE live probe launch (protocol at the bottom — piggyback on the
pending #175 door gate).
## The issues
- **#176** — purple flashing on dungeon floors at cell seams, camera-angle
dependent (Facility Hub).
- **#177** — stairs pop in/out across levels: (a) vanish on the last step
running down, (b) invisible looking into the stair room from the corridor,
(c) pop into existence on entering.
## Anchor-cell dat truth (Issue176177DungeonSeamInspectionTests)
- Corridor `0x8A02016E` floor = three abutting TEXTURED drawn portal polys
(polys 1/3/5, surface 0x08000377 → DXT1 tex 0x050026F7, `[PortalSide]`) →
under-hall `0x011E` (z=12). Reciprocal ceiling poly = NoPos (not drawn).
- The "stairs" = a RAMP owned by `0x8A020182` (inclined drawn polys, z 9…6
floor + 6…3 ceiling); `0x0183` = flat lower cell. Connections are
VERTICAL wall portals (NOT floor-portals). PortalSide flag asymmetry:
0x0182→0x0183 carries PortalSide; the back-portal does not.
- **Zero StaticObjects in all five anchor cells** (no #119-class statics, no
torch-bearing stabs → no registered point lights except the viewer fill).
- CellBSP volumes partition EXACTLY at the portal planes (no overlap zone).
- All surfaces resolve; DXT1 textures contain **zero** transparent-mode
texels (all 3-color-mode blocks, index 3 never used).
## REFUTED mechanisms (each by direct evidence — do NOT retry)
| # | Hypothesis | Killed by |
|---|---|---|
| 1 | Placeholder/missing texture (magenta class) | All surfaces resolve; drawn-poly sweep 0 misses |
| 2 | Reciprocal portal-poly z-fight | Reciprocal is NoPos (never drawn) |
| 3 | Seal depth-stamp z-fights the drawn floor-portal | Seals fire ONLY for `OtherCellId==0xFFFF` (GameWindow:11437); a sealed dungeon draws zero seals |
| 4 | Root/eye incoherence (viewer root lags the eye across portal planes) | Production camera sweep publishes coherent pairs; out-cell flips at x=85.001/88.335 — mm-exact at the planes (gate2 log). The flood DOES collapse to 1 cell under artificially incoherent inputs (ScenarioE pin) — but production inputs are coherent |
| 5 | Membership transit lag (0.330.47 m in [cell-transit]) as the render-root lag | The resolver flips within one tick-step of the plane in the harness (Issue176177SeamTransitLagTests); the logged "lag" is speed×tick quantization of the PLAYER probe, and the camera root (probe out-cell) is plane-exact |
| 6 | Flood bistability at the anchors | ScenarioC gaze sweep (2° steps, 4 pitches): 0 one-step drops; ScenarioA stair approach: ramp+lower admitted at all tested eyes/pitches |
| 7 | Staircase = EnvCell static culled by viewcone (#119 class) | Zero statics in the anchor cells; the stairs are shell geometry |
| 8 | Undefined DXT mip levels (compressed arrays skip GenerateMipmap) | Both ObjectMeshManager texture paths DECODE DXT→RGBA8 (BcDecoder) — the compressed-array branch of ManagedGLTextureArray is dead WB-heritage code; RGBA8 arrays get real mips |
| 9 | DXT1 3-color-mode alpha=0 texels + opaque-pass `discard a<0.05` / A2C | Block histogram: 0 transparent texels in all Hub floor/wall textures |
| 10 | Fog mix toward purple FogColor at distance | Fog ramp starts at `_nearRadius×192×0.7 ≈ 538 m` (radii stay 4/12 in dungeon mode); Hub sightlines ≤ ~100 m → fog term ≡ 0 |
| 11 | Lightning-flash additive (`uFogParams.z × (0.6,0.6,0.75)`) leaking indoors | `WeatherState._flashLevel` is 0 in production ("Production never TriggerFlashes") — dormant test hook. (The missing indoor gate is still real debt for when storm strobes ship.) |
| 12 | Viewer-light per-cell/per-vertex pops (hard range edge or 8-set membership flips) | The point ramp is `(1d/range)` — smoothly ZERO at the range boundary; set-membership beyond range is a zero-contribution no-op. No torches exist in the Hub cells to churn the 8-cap |
## VERIFIED-HEALTHY layers (offline pins, keep as regression assets)
- `PortalVisibilityBuilder` at the anchors: approach/descent/gaze-sweep/walk
scenarios (`Issue176177FacilityHubFloodReplayTests`) — admissions correct
and stable with coherent inputs; the ScenarioE incoherent-input collapse is
the sensitivity pin (1-cell flood when root≠eye side).
- Membership: `ResolveWithTransition` flips cells within one tick of the
portal plane, both directions (`Issue176177SeamTransitLagTests`).
- Mesh path: `CellMesh.Build` (production, GameWindow:7013) draws the
textured floor-portal strips; snapshot frustum gate (WbFrustum) is the
standard conservative p-vertex test; per-cell AABBs are vertex-derived,
8-corner transformed.
- Per-instance light sets are truly per-instance (EnvCellRenderer MDI).
## SURVIVING theories (need the live discriminator)
- **T-A (ambient flip):** any frame where `playerRoot` resolves null (or
`playerSeenOutside` defaults true) runs the OUTDOOR lighting branch —
purple sky ambient + full sun. Sun is directional-from-above → floors
(N·L≈1) catch it, walls (N·L≈0) barely → a FLOOR-selective purple-bright
flash, temporally lockable to whatever gaps CurrCell/TryGetCell. Desk
analysis found no per-frame gap trigger during plain corridor runs
(UpdatePlayerCurrCell is stale-beats-null; the registry is stable in
AP-36 dungeon mode) — but the branch inputs are exactly probed by
`[light] insideCell=` lines, so one run settles it.
- **T-B (flood with REAL production inputs):** my harness feeds synthetic
viewProj/eye. If the real per-frame inputs at the artifact moments differ
(collided camera pressed into walls near seams, damped-forward gaze), the
flood could still misbehave in configurations the sweeps missed.
`[pv-input]` prints the exact inputs + flood count per frame.
- **T-C:** unknown-unknown (GPU state, driver, MSAA resolve…). If T-A/T-B
both read clean at a flash moment → RenderDoc frame capture next.
## Also found (real, filed, not these bugs)
- **A8 double-sided shells stopgap still live**: `EnvCellRenderer.cs`
RenderModernMDIInternal maps `CullMode.Landblock → CullMode.None`
("while the architectural cause is isolated") — all cell shells draw
two-sided. Perf + correctness debt; retire under the A7/A8 lighting arc.
- **Lightning indoor gate missing** (dormant): when weather strobes ship,
`SceneLightingUbo.Build` needs the `playerInsideCell` gate or dungeons
will strobe blue-violet.
- **Flood follows floor-portals DOWNWARD from above** (ScenarioC: under-hall
network admitted at down-pitches). Retail's `portal_side` side test
(0x005a59a0: portal_side≠0 → NEGATIVE side only; ==0 → POSITIVE only;
IN_PLANE(±0.0002) → refused) *appears* to refuse this direction, but my
plane-sign reading had unresolved contradictions (CCellPortal::UnPack
normalizes portal_side at load, 0x0053ba1c/0x0053bc6a). OPEN QUESTION —
harmless-looking (extra admitted cells draw below the opaque floor,
z-buffer wins) but worth settling when the flood is next touched.
## The live probe protocol (piggyback on the #175 door gate launch)
Env (add to the standard launch block):
```
ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun — rate-limited
ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count
ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors
```
User reproduces in Facility Hub (~2 min): run the corridor across several
seams until the purple flash shows; approach the stair room from the
corridor, walk in, run down. Then close. Discrimination:
- Flash moments + `[light]` shows `insideCell=False` blips or ambient jumps
**T-A confirmed** (then root-cause the gap trigger from the same log).
- `[pv-input]` flood count drops (e.g. 12→1) at flash/pop moments while
`[light]` stays clean → **T-B confirmed** (the line carries the exact
inputs to replay in `Issue176177FacilityHubFloodReplayTests`).
- Stairs invisible while the flood contains 0x0182/0x0183 → draw-side hunt
(RenderDoc next); absent → flood-side with the printed inputs.
- All clean → T-C: RenderDoc frame capture of a flash frame.
## Apparatus shipped this session
- `tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`
— dat truth: portal polys/surfaces/draw verdicts, reciprocal coincidence,
stair geometry owner, CellBSP containment, under-hall surface colors,
DXT1 alpha histograms.
- `tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`
— production-matched flood replays: stair approach, descent, gaze sweep,
corridor walk, and the ScenarioE incoherent-root sensitivity pin.
- `tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`
— resolver cell-flip position at the seam (plane-exact pin).

View file

@ -0,0 +1,154 @@
# Pickup handoff — #176 seam-floor lighting flicker = a RUNTIME draw z-fight (RenderDoc next)
> ## ✅ OUTCOME (2026-07-06, the pickup session) — z-fight REFUTED; it was the light POOL tracking the camera; FIX SHIPPED
>
> RenderDoc was infeasible (it does not support `GL_ARB_bindless_texture` and hides
> it → our mandatory-modern startup gate throws `NotSupportedException`; the AMD RX
> 9070 XT rules out Nsight). The equivalent evidence came from the in-engine
> `ACDREAM_PROBE_SEAMDRAW` probe (`[seam-blk]/[seam-cell]/[seam-snap]/[seam-ent]/
> [seam-mask]`): **one** shell instance per seam cell at the lifted z (suspect 1
> dead), **no** floor-coincident entity (suspect-class dead), **zero** portal depth
> fans (suspect 3 dead) — but the corridor floor's applied LIGHT SET flipped
> wholesale with flood composition. Root cause: the `c500912b` visible-cell scoping
> was built on a wrong gloss — retail's `CEnvCell::visible_cell_table` is the
> **RESIDENT-cell registry** (`add_visible_cell` 0x0052de40 dat-loads absent cells),
> not the frame flood, and `insert_light` (0x0054d1b0) anchors the pool at
> **`Render::player_pos`**. Fix: `BuildPointLightSnapshot(playerWorldPos)` — resident
> collection, dynamics-first player-nearest cap; `RebuildScopedLights` deleted.
> Live-verified: flood churned 8→41 cells over a full-circle turn while the floor's
> set held the same 8 identities. Details: ISSUES #176, register AP-85, the
> correction banner in `2026-07-06-a7-per-cell-lighting-pseudocode.md`. The document
> below is kept as the historical elimination record — its DO-NOT-RETRY table
> remains valid (all those channels stay dead).
**Paste the companion prompt below into a fresh session.** Read this file, then
`claude-memory/project_render_pipeline_digest.md` (top banner), then **ISSUES #176**.
## TL;DR — what #176 actually is now
The Facility Hub corridor floor shows **hard-edged purple triangular facets that
flicker as the camera turns**, at cell seams. After a long investigation (11
hypotheses), the domain is **pinned by evidence**:
> **#176's residual is a RUNTIME draw-level depth-fight (z-fight) in the seam
> FLOOR geometry — two draws the renderer emits at ~the same depth, one carrying
> the purple portal light and one not, whose winner flips as the camera moves.
> It is NOT lighting, NOT membership, NOT dat geometry.**
The **wedge SHAPE itself is retail-faithful** (retail shows the same faceted
purple — cdb-proven, below). The only defect is the **flicker**, and it is a
render/draw problem. **The next step is a RenderDoc pixel-history capture** — do
NOT throw more lighting fixes at it (two already did nothing).
## The elimination table (evidence — this is the DO-NOT-RETRY list)
| Ruled OUT | How (hard evidence) |
|---|---|
| Lighting **brightness** | per-light cap on the dynamic branch (`mesh_modern.vert`) → **no visual change**, reverted |
| Lighting **selection** | `SelectForCell` (all dynamic lights per cell, retail-exact) → **no visual change** |
| Light-set **camera-cap churn** (the OLD "confirmed" #176 theory) | visible-cell scoping shipped (`c500912b`); probe-proven ~285 through-floor lights dropped/frame — symptom unchanged. **REFUTED.** |
| **Membership / the "flap"** | `ACDREAM_PROBE_FLAP`: render cell = `0x8A020164` on **100%** of 188,732 frames across **526 distinct camera angles**, `res=None` always; `ACDREAM_PROBE_PVINPUT` flood is stable-per-angle (17/10/8…), never oscillates at a fixed view; 1 `[cell-transit]` (the spawn teleport) total |
| **Dat-geometry z-fight** | `Issue176177DungeonSeamInspectionTests.CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` seeded on the ACTUAL cell `0x8A020164` + neighbors → **zero** coplanar drawn pairs at the z=6 corridor floor (only the benign same-cell z=12 under-hall floor tiling in `0x011E`) |
| A2C / alpha-hole see-through | corridor floor surface `0x08000377` is **fully opaque** (`alpha0Texels=0`, `transl=0.00`) |
| Translucent under-surface blend | the one translucent colored surface `0x08000034` is `NoPos` (not drawn) |
| Flat (per-face) normals | corridor floor uses **smooth per-vertex dat normals** (center `(0,0,1)`, corners tilted ~27° — retail-style edge smoothing), NOT flat (`CellVertexNormals_SmoothOrFaceted_Dump`) |
## What retail actually does (cdb-proven — the wedge is faithful)
`tools/cdb/issue176-floor-light.cdb` traced live retail's `config_hardware_light`
standing in the Hub (binary↔PDB MATCH, GUID `9e847e2f…`):
- Retail applies **4 intensity-100 magenta portal lights** (`d3dIdx 36`,
`color=(0.784,0,0.784)`, `falloff=6`) **+ the viewer fill** (`d3dIdx 1`,
`intensity=2.25 falloff=10`) as **D3D hardware lights to EVERY Hub cell**
(`0102,0103,0109,0111,0112,0115,0118,0119,011a,011e,011f,0121,0122,0123…`),
every frame, stable.
- So retail's cell floors ARE lit by the portals via per-vertex hardware T&L over
the same smoothed fan-corner normals → **the same faceted purple wedges**.
→ the wedge SHAPE is retail-faithful; only the flicker is ours.
The intensity-100 magenta light is the **portal** (2 portal weenies
`0x000F4247`/`0x000F4248` in cells `0x8A020118`/`0x8A020119`, `dyn=1`). Confirmed
via the `[light-detail] owner=/cell=/dyn=` probe fields.
## The residual bug — a runtime double-draw of the seam floor
Since it's not dat geometry, the renderer is emitting the corridor floor (or a
coincident surface) **twice at ~the same depth**. Prime suspects to check in
RenderDoc (do NOT assume — capture and read):
1. **The `+0.02 m` shell render lift** (`PortalVisibilityBuilder.ShellDrawLiftZ`,
register **AP-32**, the #130 saga). If the shell floor draws lifted while some
other pass draws the same floor unlifted, 0.02 m z-fights at grazing angles.
2. **The A8 double-sided cell-shell stopgap** (**#178**, LIVE):
`EnvCellRenderer.RenderModernMDIInternal` `if (cullMode == CullMode.Landblock)
cullMode = CullMode.None;` — every cell shell draws two-sided. Retiring this is
already an open issue; test whether it's implicated.
3. **Portal depth-writes**`RetailPViewRenderer.DrawExitPortalMasks` /
the interior-root depth-clear+seal path. Seals are supposed to fire only for
`OtherCellId==0xFFFF` (a sealed dungeon draws zero), but VERIFY in the capture
that no depth-mask lands on the floor-portal plane (the corridor floor IS a
floor-portal poly to the under-room, surf `0x08000377`, z=6).
## RenderDoc plan (the definitive next step)
1. Launch acdream under RenderDoc (`renderdoc` → capture `AcDream.App`, or inject).
Standard `ACDREAM_LIVE` env from CLAUDE.md.
2. Stand in cell `0x8A020164` corridor, at an angle where the floor flickers.
Capture a frame (F12).
3. In RenderDoc: **right-click a flickering floor pixel → "History"** (pixel
history). It lists every draw that touched that pixel + its depth + pass/fail.
4. **Two draws at ~equal depth writing that pixel = the z-fight.** Identify which
two passes they are (shell vs shell-lift vs portal-mask). Report the draw
names + depths back — that names the fix.
5. Fix at the source (make the two draws not coincide: drop the redundant draw,
or reconcile the lift), then re-gate. No band-aid depth-bias (that's a
workaround — see CLAUDE.md).
## What's committed / the state
- **`c500912b`** (in `main`): visible-cell light **scoping**`LightSource.CellId`
+ `BuildPointLightSnapshot(camPos, visibleCells)` (retail `add_*_lights over
visible_cell_table`) + the `[indoor-light]` probe. Retail-faithful; fixed the
camera-eviction latent bug. Register **AP-85** corrected. Corrected the old
handoff's imprecise "insert_light = per-cell" framing — the real model is
visible-cell-scoped collection into a small player-nearest pool; see
`docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md`.
- **This commit**: `LightManager.SelectForCell` (all dynamic lights per EnvCell,
retail `minimize_envcell_lighting`) wired into `EnvCellRenderer.GetCellLightSet`;
the `[light-detail]` owner/cell/dyn probe; `CellVertexNormals_*` dump;
`tools/cdb/issue176-floor-light.cdb`. **Retail-faithful, NOT the flicker fix.**
- Suites green: Core 2599 + 2 skip. All on `main`.
## Apparatus (reuse, don't rebuild)
- `ACDREAM_PROBE_FLAP=1` — per-frame `[flap-cam]` render cell + `[flap]` portal
decisions (proved membership stable).
- `ACDREAM_PROBE_PVINPUT=1` — per-frame `[pv-input] flood=N eye= yaw=`.
- `ACDREAM_PROBE_LIGHT=1``[light]` + `[light-detail]` (now with owner/cell/dyn).
- `ACDREAM_PROBE_INDOOR_LIGHT=1``[indoor-light]` scoped-pool SET composition.
- `tools/cdb/issue176-floor-light.cdb` — retail light-setup trace.
- `Issue176177DungeonSeamInspectionTests` — dat truth (coplanar sweep, floor
surfaces, vertex normals); `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs`
seed = `{0164,0165,016E,017A}`+neighbors.
## Repro + launch protocol
`dotnet build` green FIRST (close any running client — a client may still be up;
the user manages lifecycle, do NOT kill it, ASK). Standard `ACDREAM_LIVE`
PowerShell launch. Test char spawns in the Facility Hub; the flickering corridor
is cell **`0x8A020164`** (player z≈5.9). Turn back-and-forth across a seam to
reproduce. Strip `\000` before grep (PowerShell Tee = UTF-16).
## Process lesson (paid for in full this session)
**Get a screenshot AND a retail cdb trace EARLY for a render bug — before
theorizing.** This session chased 11 inference-based hypotheses over many
launches; the screenshot (faceted triangles) + the cdb trace (retail applies the
same lights → faithful shape) would each have redirected the whole thing in one
step. The project's own rules already say this; follow them.
## Also open
- **#177** — dungeon stairs pop in/out (`0178/0182/0183`) — a SEPARATE
portal-visibility miss, not lighting. Untouched this arc.

View file

@ -0,0 +1,137 @@
> **🅿️ SUPERSEDED 2026-07-07 — #177 is PARKED and this doc's premise is WRONG.** The
> "portal-visibility miss" framing below was DISPROVEN by a live retail cdb trace
> (retail's flood collapses identically to ours). Do NOT follow the flood/clip workflow
> here. Current truth + the fresh lead (step statics) live in **ISSUES #177 (session-3 +
> 3b blocks)** and the render digest top banner. Kept only for the apparatus map.
# Pickup handoff — #177 dungeon stairs pop in/out (portal-visibility miss at the stair cells)
**Paste the companion prompt (bottom) into a fresh session.** Read this file, then
`claude-memory/project_render_pipeline_digest.md` (top banners), then **ISSUES #177**
(full entry — including the superseded lighting attribution and why it was re-attributed
to VISIBILITY at the 2026-07-06 gate).
## TL;DR — what #177 is
Facility Hub (0x8A02), the staircase connecting two levels (corridor 0x0178 → ramp
0x0182 → lower room 0x0183): (a) **invisible looking into the stair room from the
corridor**, (b) **pops into existence on entering the room**, (c) **the last step
vanishes running down**. The 2026-07-06 gate PROVED this is NOT lighting (the A7
visible-cell light fix shipped and the stairs were still missing looking back from the
corridor) — it is a **portal-flood admission miss**: the stair cells are not reached by
`PortalVisibilityBuilder` from the viewer's cell until the viewer crosses in. The
digest tags it "#119 class, dungeon edition; floor-portal level links."
**Acceptance:** the staircase renders whenever its room is visible through the
connecting opening, and stays rendered through the full descent.
## Where to start (the apparatus already exists)
1. **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`** —
headless flood replays against the REAL dat, with two scenarios written for exactly
this issue and never fully mined:
- Scenario A (stair approach): eye in corridor 0x0178 at several x positions +
pitches, printing whether ramp 0x0182 / lower 0x0183 are admitted.
- Scenario B (descent): eye path down the ramp, printing per-step admitted-set
diffs (the "last step vanishes" case).
**Run these FIRST** — the miss should reproduce headlessly, and the printed
admitted sets name which portal hop fails.
2. **`Issue181VisFlapReplayTests.cs`** — the same-file pattern for perturbation
sweeps + per-cell view-region AREA measurement (the shoelace diagnostic). Cell
**0x8A020181** — right next to the stair complex 0x0182/0x0183 — rides a
knife-edge zero-area admission; the #177 miss and the #181 sliver are plausibly
the SAME flood defect seen two ways (a floor-portal chain that clips to nothing
from above).
3. **The builder:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`. The
admission gates per portal hop, in order: the side test (`CameraOnInteriorSide`,
PortalSideEpsilon 0.01 — AP-19), then `ClipPortalAgainstView` (homogeneous clip
against the parent's accumulated region; `clippedRegion.Count == 0` ⇒ the flood
stops there). For a DOWNWARD stair chain the suspect class is the FLOOR-portal
hop: a horizontal portal seen edge-on from the corridor clips to ~zero area
(exactly the 0x0181 sliver signature) and the chain below never floods.
`ACDREAM_A8_DUMP_PV=1` dumps per-portal census + EXIT-PROJ/EXIT-CLIP lines for
the camera cell (first 2 builds per cell).
4. **Retail oracle:** retail PView floods portals with per-poly clip
(`polyClipFinish` — the W=0 clip pseudocode doc 2026-06-11) and DOES show stair
rooms through their openings. If our clip collapses a genuinely-visible aperture
to zero, compare `ClipPortalAgainstView` against `PView::ClipPortals`/`GetClip`
(decomp:433572) at the failing hop's exact geometry. #119's lesson applies:
visibility must derive from the same data that gets drawn
(`feedback_culling_bounds_from_drawn_data`).
## What tonight's #180/#181 session gives you for free
- All camera/sweep mechanisms are Ghidra-verified faithful (stateful sought,
adjust_to_plane, adjust_sphere_to_poly, viewer radius-anchored stepping) — commits
`48aaab81`/`f10fe4e9`/`3f34bca0`. The camera's `ViewerCellId` is trustworthy; the
render roots at it (Option A).
- The wall-pressed camera wanders ~0.5 mm/frame in a bit-exact 12-frame limit cycle
(`Issue181WallPressEquilibriumTests` — measured, all local algorithms verified;
plausibly retail-class). Any admission knife-edge within that wobble band flaps
~10 Hz (`[flap]` vis 31↔32, whole-session). For #177 this matters at stair
THRESHOLDS: expect flappy admission exactly at descent transitions.
- Lighting is now retail-shaped: resident+player-anchored pool (d8984e87), idempotent
re-apply (`87cddce2`), stationary fixtures on the verified static curve
(`233b469b`). Do NOT re-open lighting for #177 — the gate already refuted it.
- **#181 is PARKED** (see its ISSUES entry for the still-open leads). Do not resume
it inside the #177 arc; if stair work surfaces new flicker evidence, append to
#181's entry instead.
## Watchouts
- The old #177 root-cause text (128-cap light eviction) is SUPERSEDED — kept in the
entry for history. The re-attribution is the ⚠️ UPDATE block at the top of #177.
- The flood replay's ViewProj must match production FOV/aspect (π/3, 16:9) — the
Issue181 test does; the older Issue176177 scenarios use 1.2 rad / 1280×720 (fine
for admission questions, but keep in mind for NDC-sensitive comparisons).
- `Issue120ReciprocalPingPongTests.LoadAllInteriorCells` + `CornerFloodReplayTests.
ResolveDatDir` are the shared fixtures loaders (App.Tests); Core.Tests has its own
`ConformanceDats`.
- A client may be running — the user manages its lifecycle; ASK before rebuilding if
the App DLL is locked (MSB3027 = lock, not code).
## State
- Branch `claude/vigorous-joliot-f0c3ad` @ `233b469b`+ (this handoff's commit).
Suites green: Core 2600+2skip / App 733+2skip / UI 425 / Net 385 / Content 56.
- Test char spawns in the Facility Hub corridor (0x8A020142 area); the stair complex
is 0x0178/0x0182/0x0183 (+0x0181 adjacent).
- Launch recipe + graceful-close rules: CLAUDE.md "Running the client". The
GDI-capture + keybd_event autonomous visual loop scripts live in this session's
scratchpad (capture-still.ps1, imgdiff.ps1, imgdiffmap.ps1, crop.ps1) — recreate
from the #180 handoff's recipe if the scratchpad is gone.
## Companion prompt (paste into the fresh session)
```
Fix #177 — Facility Hub stairs pop in/out across levels (invisible from the
corridor, pop on entry, last step vanishes running down). The 2026-07-06 gate
re-attributed this from lighting to a PORTAL-FLOOD ADMISSION MISS at the stair
cells (0x8A020178 corridor → 0x0182 ramp → 0x0183 lower; 0x0181 adjacent) — do
NOT re-open lighting (gate-refuted; the A7 fixes are in and verified).
READ FIRST, in order:
1. docs/research/2026-07-06-177-stairs-pickup-handoff.md (apparatus map, the
#181 park boundary, watchouts)
2. claude-memory/project_render_pipeline_digest.md (top banners)
3. docs/ISSUES.md #177 (the ⚠️ UPDATE block supersedes the old root cause)
START by running the existing headless replays (Issue176177FacilityHubFloodReplay
Tests Scenarios A/B — real dat, no client): they print per-pose admitted sets for
the stair cells and should reproduce the miss offline. Then trace the failing
portal hop through PortalVisibilityBuilder's gates (side test → ClipPortalAgainst
View; the floor-portal seen edge-on clipping to ~zero area is the suspect class —
the same signature as #181's 0x0181 sliver next door). Retail oracle for the clip:
PView::ClipPortals/GetClip (decomp:433572) + the 2026-06-11 polyClipFinish W=0
pseudocode doc. Workflow: replay → identify the gate → read retail → pseudocode →
port → the replays become pins → autonomous visual loop (stair approach/descent
drive) → user gate.
#181 (the pressed-camera flicker) is PARKED with its evidence ledger in ISSUES —
if stair work surfaces flicker evidence, append there; don't resume it in this arc.
State: branch claude/vigorous-joliot-f0c3ad, suites green (Core 2600+2skip / App
733+2skip). Test char spawns in the Facility Hub corridor. A client may be
running — the user manages its lifecycle; ASK before rebuilding if the App DLL
is locked.
```

View file

@ -0,0 +1,156 @@
# Pickup handoff — #180 camera-sweep eye strobe (the #176 "stripes") — port retail's stateful sought-position
**Paste the companion prompt (bottom of this file / the session log) into a fresh
session.** Read this file, then `claude-memory/project_physics_collision_digest.md`
(top banner) + `claude-memory/project_render_pipeline_digest.md` (top banner), then
**ISSUES #180** (and #176's gate-2 note).
## TL;DR — what #180 is
The user's #176 residual — "geometrical patterns, triangles, that get weird
stripes… especially when I push the camera to the wall or the openings; if I zoom
out and the camera does not touch the walls I get no pattern" — is **NOT a render
defect. The camera-collision sweep is BISTABLE while the compressed chase boom
moves along a wall**, and the per-frame view hard-cuts tear-interleave on screen:
> `[flap-sweep]` (launch-176-cameye.log, frames ~3860738861): consecutive sweeps
> whose INPUT moved **~1.4 mm** (the player's glide) flipped the OUTPUT **0.27 m
> along the boom** — `pulledIn` 0.27 ↔ 0.53, eye (78.500,38.633,3.845) ↔
> (78.669,38.815,3.938) — re-flipping every ~510 frames. All 368,357 sweeps
> returned `ok=True` (this is NOT the fallback path — the sweep itself has two
> first-contact solutions at an r±ε graze on the corridor's double-faced slabs,
> the #137 threshold family, camera edition). At ~1700 fps with no vsync, each
> monitor refresh interleaves scanline slices of BOTH views → the fine
> stripe/hatch patterns (worst near seams where the 0.27 m parallax is largest),
> flickering with movement. Parked camera = static (all prior "camera stable"
> evidence was parked-camera; the strobe needs motion + a compressed boom).
This very plausibly also produced the ORIGINAL #176 seam flashes (one of the two
alternating eyes sees the purple under-room parallax, the other doesn't).
## Eliminated for the stripes (DO-NOT-RETRY — all isolation-killed)
| Ruled OUT | How |
|---|---|
| Texture / grout-line interaction | `ACDREAM_LIGHT_DEBUG=3` (raw vLit field, texture ignored) still shows the pattern |
| The per-cell shell clip trim (gl_ClipDistance) | `ACDREAM_CLIP_DEBUG=1` (shells drawn WHOLE, slots forced 0) — user: "Not gone, still there" |
| Coplanar dat geometry | 2 cm-epsilon sweep: corridor floor (z=6) clean; the only stack is the z=12 under-hall quad×4 — SAME-mesh, bit-identical depth ⇒ stable winner, cannot moiré |
| Shell double-instance / floor-coincident entity / portal seals | `[seam-*]` probe: n=1 per cell at the lifted z; portal entities at z=12.05; zero depth fans (sealed dungeon) |
| Light-set churn under movement | `[seam-blk]`: the floor's applied set held the SAME 8 identities through a 148k-frame session incl. ~12k distinct positions |
| The light pool tracking the camera | FIXED `d8984e87` (resident collection, player anchor) — verified: flood churned 8→41 across a full turn, set constant. Do NOT re-open; re-gate VISUALLY only after #180 |
| SweepEye itself misporting update_viewer | Read side-by-side: flags 0x5c, AdjustPosition seating, fallbacks all match 0x00453ce0. The bistability is in the transition's contact solution, not the port shape |
## The retail anchor (the fix direction — read BEFORE coding)
Retail's camera target is **STATEFUL**; ours re-demands the full boom every frame:
- `SmartBox` per-frame (pseudo-C line ~91842, addr 0x00452d75):
`CameraManager::UpdateCamera(this->camera_manager, &ret, &this->viewer)` — the
argument is the **CURRENT swept viewer**, and the RETURN is assigned to
`this->viewer_sought_position` (0x00452d84). So the next frame's sweep target
derives from the collided position — the target CONVERGES onto the wall
contact and re-extends gradually; the knife-edge full-length ray is not
re-rolled per frame. That is why retail's camera glides along walls instead of
strobing.
- `CameraManager::UpdateCamera` = **0x00456660** (pseudo-C line ~95505). Only the
head was read this session (offset-movement integration, pivot query); the
sought-position derivation in the tail is UNREAD — **read it fully, write the
pseudocode doc (workflow step 3), then port**.
- `SmartBox::update_viewer` = **0x00453ce0** (line ~92761) — already faithfully
ported by `PhysicsCameraCollisionProbe.SweepEye`; leave it alone.
- **No damping band-aid on the swept result without the UpdateCamera reading**
(the no-workaround rule; the user has already reverted redesigns that replaced
retail mechanisms).
Port site: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (the desired-eye /
boom derivation) — it must consume the previous swept viewer per retail.
## Also open from this arc
- **Site-A weenie light-registration LEAK**: `[seam-ent]` showed one portal cell's
I100 light ×2 → ×4 within a session — re-CreateObject re-registers the light
under a fresh `entity.Id` (GameWindow ~3679: `Id = _liveEntityIdCounter++`)
without tearing down the previous registration. The wash intensifies over
time. Fix with #180's gate or fold into A7 (noted in ISSUES #176).
- **#176 re-gate** happens only after #180 lands: camera pressed into
walls/openings while moving → no patterns; corridor seam turn → steady purple
wedge (the faceted SHAPE is cdb-proven retail — only instability is a defect).
- After BOTH pass: strip the throwaway probes in one cleanup commit —
`[seam-*]` (ACDREAM_PROBE_SEAMDRAW), `ACDREAM_LIGHT_DEBUG` modes,
`ACDREAM_CLIP_DEBUG`. Keep `[flap-sweep]` (it rides ACDREAM_PROBE_FLAP).
## Apparatus (reuse, don't rebuild)
- `ACDREAM_PROBE_FLAP=1``[flap-sweep]` per-sweep line: `desiredBack/eyeBack/
pulledIn/in=/out=` — THE #180 instrument (plus [flap]/[flap-cam]).
- `ACDREAM_PROBE_PVINPUT=1` → per-frame `[pv-input] eye=` at 6 dp.
- **The autonomous visual loop** (no user needed, works where RenderDoc can't —
RenderDoc doesn't support GL_ARB_bindless_texture and the modern gate throws):
1. Launch via `dotnet run` (background) with probes; wait ~12 s.
2. Input: `keybd_event` **with real scan codes + KEYEVENTF_EXTENDEDKEY**
(GLFW decodes scancodes; vk-only silently does nothing). Arrows: Left
0x25/0x4B, Right 0x27/0x4D, Down 0x28/0x50. `SetForegroundWindow` first,
verify with `GetForegroundWindow`. Hold-key = down, Sleep, up.
3. Capture: `GetWindowRect` + `Graphics.CopyFromScreen` → PNG in the
scratchpad → **the Read tool renders it** (take 3 captures ~150 ms apart —
frame-pair diffs expose per-frame strobes).
4. Repro recipe: back the character into a wall (hold Down ~2.5 s) — the boom
compresses; the strobe fires DURING/just after motion near the wall.
5. Close gracefully via `CloseMainWindow()` (ACE session clears in ~5 s).
- Launch logs on disk (worktree root): `launch-176-cameye.log` (THE strobe
capture), `launch-176-user-gate.log` (148k-frame user session incl. the light
leak), `launch-176-seamdraw.log`, `launch-176-fix-verify.log`.
- `ACDREAM_LIGHT_DEBUG=1/2/3` + `ACDREAM_CLIP_DEBUG=1` — shader/clip isolation
toggles (throwaway; strip at cleanup).
## State
- `main` = worktree branch `claude/vigorous-joliot-f0c3ad` = **`6aabe0b5`**
(lighting fix `d8984e87` + isolation apparatus `6aabe0b5`). Suites green
(Core 2599+2skip / App 726+2skip / UI 425 / Net 385).
- Register: AP-85 rewritten (resident+player-anchored pool; residual = single
128 pool vs retail 7-dyn/40-static). The a7 pseudocode doc carries a
correction banner (`visible_cell_table` = RESIDENT registry — the
identifier-name-≠-semantics lesson, memory
`feedback_bn_decomp_field_names` class 4).
- A client may be running — the user manages its lifecycle; ASK before
rebuilding if the App DLL is locked (MSB3027 = lock, not code).
- The test char spawns in the Facility Hub corridor (cell `0x8A020164`).
## Companion prompt (paste into the fresh session)
```
Fix #180 — the camera-collision sweep strobes the eye 0.27m per few frames when
the compressed chase boom moves along walls; at ~1700fps the tear-interleaved
views ARE the #176 "stripes/triangles" the gates keep failing on. The mechanism
is probe-pinned; every render suspect is isolation-killed. Do NOT re-investigate
the render, the light pool (fixed d8984e87, data-verified), or SweepEye's port
(faithful, exonerated).
READ FIRST, in order:
1. docs/research/2026-07-06-180-camera-sweep-strobe-handoff.md (evidence,
DO-NOT-RETRY table, retail anchors, the autonomous visual-loop recipe)
2. claude-memory/project_physics_collision_digest.md (top banner)
3. docs/ISSUES.md #180 (+ #176's gate-2 note)
THE FIX = port retail's STATEFUL camera target: SmartBox (0x00452d75) feeds the
CURRENT swept viewer into CameraManager::UpdateCamera (0x00456660) and assigns
the RETURN to viewer_sought_position (0x00452d84) — the target converges onto
the wall contact instead of re-rolling the full knife-edge boom ray per frame
like RetailChaseCamera does. Workflow: read UpdateCamera's tail (the sought
derivation — UNREAD so far, pseudo-C line ~95505), write the pseudocode doc,
port into RetailChaseCamera. NO damping band-aids on the swept result.
Verify autonomously with the handoff's visual loop (keybd_event scan-codes +
GDI window capture + [flap-sweep]): back into a wall, confirm consecutive-frame
eye deltas stay continuous and no stripe patterns in captures. Then the user
gate covers #180 AND the #176 re-gate (steady purple wedge at the seam — the
wedge SHAPE is retail, cdb-proven). After both pass: fix the site-A light-
registration leak (one portal light stacked ×4/session — re-CreateObject
re-registers under fresh entity ids) and strip the throwaway probes
([seam-*], ACDREAM_LIGHT_DEBUG, ACDREAM_CLIP_DEBUG) in a cleanup commit.
State: main @ 6aabe0b5, suites green (Core 2599+2skip). Test char spawns in the
Facility Hub corridor 0x8A020164. A client may be running — the user manages
its lifecycle; ASK before rebuilding if the App DLL is locked.
```

View file

@ -0,0 +1,315 @@
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
> ## ⚠️ CORRECTION #2 (2026-07-06, later the same day — the #176 flicker root cause)
>
> **§1.3's gloss is WRONG: `CEnvCell::visible_cell_table` is NOT "the portal-flood
> visible set".** It is a **static class member** — `HashTable<uint, CEnvCell*>
> CEnvCell::visible_cell_table` (defined 0x0081dd28, ctor 0x006fea4a) — the
> **RESIDENT-EnvCell registry**. Proof from the named decomp:
> - `CEnvCell::add_visible_cell(id)` (0x0052de40) returns the cell if present,
> otherwise **loads it from the dat** (`DBObj::Get(QDID(id, 3))`) and inserts it.
> Entries leave only via the flush machinery (`cell_flush_table` /
> `flush_cells` 0x0052def0) — **camera gaze can never remove a cell**.
> - It is populated at cell activation: a cell adds **itself + its entire dat
> visible-cell list** (`add_visible_cell(this->m_DID.id)` 0x0052e228 +
> `add_visible_cell(this->stab_list[i])` loop 0x0052e24a).
> - `CEnvCell::add_dynamic_lights` (0x0052d410) walks the **whole table**
> (`begin(&visible_cell_table)`), per frame, from 0x00452d30 (SmartBox).
> - `Render::insert_light` (0x0054d1b0) sorts the pool by distance to
> **`Render::player_pos`** (0x0054d1dd0x0054d214; set from
> `player->m_position`, SmartBox 0x00453d3a, viewer fallback 0x00455ab6).
>
> **So retail's pool = all RESIDENT cells' lights, capped nearest-the-PLAYER —
> a function of player position only, camera-invariant.** The §3.1 "slice 1"
> shipped below (flood-scoped `BuildPointLightSnapshot(camPos, visibleCells)`,
> `c500912b`) implemented the wrong gloss and WAS the #176 seam-floor blink: the
> under-room portal purples entered/left the pool as the camera turned (probe
> `[seam-blk]`: flood churned 8→41 cells across a full turn). Fixed same day —
> the scoping + the `RebuildScopedLights` callback are deleted;
> `BuildPointLightSnapshot(playerWorldPos)` collects every registered lit light
> (registration lifecycle = residency) with dynamics-first player-nearest
> capping. §2's "Retail never has 366 candidates" and the through-floor
> paragraph are likewise wrong: retail's Hub pool DOES contain the under-room
> purples (the cdb capture showed them applied to every Hub cell — the wedge is
> faithful). Register row AP-85 rewritten. Lesson (feedback_bn_decomp_field_names
> class): a decomp identifier's NAME is not its semantics — verify what
> POPULATES a structure before adopting its name as its meaning.
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
match it line-for-line.
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
> the *collection phase*: retail rebuilds that global pool **each frame from only
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
> This is a *better* fit for acdream than the handoff's framing, because acdream
> already computes the visible-cell set every frame (the portal flood).
---
## 1. The retail model, as source-confirmed
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
path feeds it from the landblock's static object lights (caller at line ~285976,
`CObjCell::add_light(cell, lights->lightobj + i)`).
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
### 1.2 A cell pushes its own lights to the global pool
```
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
```
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
block-offset distance math).
### 1.3 Per frame, ONLY visible cells contribute (the crux)
```
CEnvCell::add_dynamic_lights(): # 0x0052d410
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
CObjCell::add_dynamic_to_global_lights(cell)
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
for cell in CEnvCell::visible_cell_table: # SAME visible set
cell.init_static_objects()
CObjCell::init_objects(cell)
CObjCell::add_static_to_global_lights(cell)
```
`visible_cell_table` is the set of cells reached by the portal flood from the
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
lights to the global pool.** This is the entire reason retail doesn't churn.
### 1.4 The global pool is small and player-sorted (`insert_light`)
```
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
distsq = 0
if info.type == 0: # point light
# squared distance from THIS light to the PLAYER, across the cell block offset
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
# capped at maxCount; when full, evict the farthest-from-player.
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
insert_light(max_static_lights, &world_lights.num_static_lights,
world_lights.static_lights, world_lights.sorted_static_lights,
info, cellId, frame, max_dynamic_lights + 1)
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
info, cellId, frame, 1)
```
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
```
Render::minimize_object_lighting(): # 0x0054d480
reset_active_lights_state()
used = 0
# DYNAMIC lights first (priority), pre-sorted nearest-player:
for i in 0 .. num_dynamic_lights:
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
add_active_light(i, 2); used += 1
else: dynamic_light_used[i] = 0
# STATIC lights fill remaining slots:
for i in 0 .. num_static_lights:
if used >= 8: static_light_used[i] = 0; continue
L = sorted_static_lights[i]
if L.info.type != 0: # non-point (directional): always use
add_active_light(i, 1); used += 1
else: # point: sphere-overlap test
reach = L.range + local_object_radius
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
add_active_light(i, 1); used += 1
else: static_light_used[i] = 0
enable_active_lights()
```
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
Minor — parity item, not the #176/#177 cause.
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
```
calc_point_light(vertex, &r, &g, &b, info):
d = |info.offset.origin - vertex.pos|
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
if d < range:
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
if faces_light:
atten = <1/d-ish curve, x87 SEE WARNING>
f = atten * (1 - d/range) * info.intensity
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
g += clamp(f * info.color.g, ..)
b += clamp(f * info.color.b, ..)
```
> ✅ **CURVE VERIFIED via Ghidra (2026-07-06 night, the #181 arc).** The exact
> retail static point-light evaluation (0x0059c8b0, clean decompile):
>
> ```
> L = light.origin vertex.pos ; d² = |L|² ; d = √d²
> range = falloff · static_light_factor # ≈ 1.3
> if d ≥ range: contribute nothing
> wrap = (1/(2·LIGHT_POINT_RANGE)) · ( dot(N, L) + (2·LIGHT_POINT_RANGE 1)·d )
> if wrap ≤ 0: contribute nothing # back-facing beyond the wrap term
> denom = (d² > 1) ? d³ : d # 1/d³ beyond 1 m, 1/d inside
> f = (1 d/range) · intensity · (wrap / denom)
> r += min(f·color.r, color.r) # per-channel clamp to the light's
> g += min(f·color.g, color.g) # own colour (never exceeds it)
> b += min(f·color.b, color.b)
> ```
>
> (`LIGHT_POINT_RANGE` is a global — read its value when porting; if 1.0 the wrap
> reduces to `0.5·(dot(N,L) + d)`, matching the earlier BN gloss.) Against the
> DYNAMIC D3D path our server-weenie fixtures currently use (1/d, range×1.5), a
> fixture at 3 m is ~an order of magnitude hotter than retail — quantifying the
> #176 magenta wash. Still pin with a conformance test on port.
---
## 2. Why #176/#177 happen in acdream (refined root cause)
acdream `LightManager` registers **every** fixture permanently into `_all` (server
weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at
`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the
Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject`
can only choose from the surviving 128, so an in-range torch of a *visible* cell that
ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops
as the camera moves (#176 seam flash / #177 stair-room pop-in).
**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY
the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells —
under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop
that essentially never fires because the input is already visibility-scoped.
This also explains the **through-floor purple wash** the cap-raise exposed: acdream's
flat world-space sphere-overlap of all 366 lights let an under-room portal light reach
up through a solid floor. Retail's under-room cell isn't in the corridor's
`visible_cell_table` (the flood doesn't pass through the solid floor), so its light
never enters the pool. Per-cell reach = *the light is only a candidate when its cell
is visibly flooded.*
---
## 3. The fix (materially different from "just uncap MaxGlobalLights")
**Port the visibility-scoped per-frame collection**, not a bigger cap:
1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`;
populate at every registration site from the cell/landblock in scope). Retail's
`add_*_light(info, cellId, frame)` carries exactly this.
2. **Build the per-frame point-light pool from ONLY the currently-visible cells**
the portal-flood set the renderer already computes — instead of the whole `_all`
set. This is retail's `add_*_lights over visible_cell_table`. The pool is then
naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's
40+7 as a documented backstop). The Skip'd end-state pin
(`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`)
asserts exactly this: an in-range light of a visible cell is never camera-evicted.
3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether
the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d)
for genuinely moving lights (portal swirls, projectiles). See §1.6 warning.
4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped)
pool on. Invisible at cap 128; see the handoff for the two leading guesses.
5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin.
### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping)
The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object
(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was
that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA.
The fix scopes that pool to visible cells. Concretely:
1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell
(insert_light arg6 → RenderLight +0x6c).
2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light.
3. **Both registration sites tag the owning cell** from `entity.ParentCellId`:
- Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`).
- Site B dat EnvCell statics — `GameWindow.cs:~7696` (same).
- Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame
`add_dynamic_light(&viewer_light, objcell_id)` is unconditional).
4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)`** —
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
portal flood + all cell/entity draws happen LATER, INSIDE
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
fly) never invokes the callback and keeps the legacy null-scope full pool.
6. **Validation apparatus**`ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
give (#176/#177 lived in set membership).
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
---
## 4. Source anchors (for the register + future sessions)
| Retail fn | Addr | Role |
|---|---|---|
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |

View file

@ -0,0 +1,115 @@
# `BSPTREE::adjust_to_plane` + `CPolygon::adjust_sphere_to_poly` — pseudocode (#180 sawtooth fix)
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. Read 2026-07-06
while root-causing the #180 residual (the camera-sweep sawtooth: PathClipped
stops landing a whole transition step short of the wall contact).
| Function | VA | pseudo-C line |
|---|---|---|
| `BSPTREE::adjust_to_plane` | `0x00539bf0` | 323440 |
| `BSPTREE::collide_with_pt` (caller) | `0x0053a090` | 323615 |
| `CPolygon::adjust_sphere_to_poly` | `0x00538170` | 321999 |
## ⚠️ ACE'S PORT OF BOTH FUNCTIONS IS WRONG — do not re-import it
ACE `BSPTree.cs:43-85 adjust_to_plane` + `Polygon.cs:99-114
adjust_sphere_to_poly` decode the retail x87 control flow incorrectly, and the
result is a function that **always returns false** (trace it: `touchTime==1`
re-places the sphere at t=1 — the unchanged check position — and re-tests; any
`touchTime<1` iterates 15 times doing nothing; the `<0.02` convergence exit
returns false — inverted). ACE never noticed because `PERFECT_CLIP` (0x40) is
a viewer/camera flag the SERVER never meaningfully exercises. acdream's
pre-fix `BSPQuery.AdjustToPlane`/`AdjustSphereToPoly` copied ACE's shape and
inherited the dead machinery — the memory lesson
`feedback_bn_decomp_field_names` class 3 (ACE mush-decode wrong in branches
ACE never runs; the retail binary outranks it).
**Consequence (the #180 sawtooth / the ORIGINAL #176 strobe):** with
`AdjustToPlane` always failing, `collide_with_pt`'s PerfectClip branch always
fell to the bare `Collided` return → the transition reverted the colliding
step to the PREVIOUS step boundary. A PathClipped camera sweep therefore
stopped a whole sub-step (≤ 1 sphere radius, ~0.250.3 m) short of the actual
contact — quantized to step boundaries. The pre-fix full-boom camera flipped
between 1-step and 2-step backoffs on mm input drift (the measured
`pulledIn` 0.27 ↔ 0.53 = one step vs two steps of the ~1.6 m/6-step sweep);
the stateful-sought camera turned it into a re-extend/clip-0.27 m sawtooth at
~19 Hz. Same root: the stop never resolved to the contact point.
## `CPolygon::adjust_sphere_to_poly` — 0x00538170
Returns the parametric time along `movement` at which the SPHERE SURFACE
touches the polygon's plane, approaching from the side the start position is
on. (The retail tail is x87 compare-bit mush; the reconstruction below is the
only shape consistent with the visible operand loads + the caller's use.)
```
adjust_sphere_to_poly(this, CSphere* sphere, Vector3 curPos, Vector3 movement) -> float:
dpPos = dot(plane.N, curPos) + plane.d // plane distance of the START (0x0053818d)
if |dpPos| < sphere.radius: return 1.0 // start already within one radius of the
// plane — no forward touch time (0x005381a4)
dpMove = dot(plane.N, movement) // (0x005381ca)
if |dpMove| <= 2e-4: return 0.0 // moving parallel to the plane (0x005381d2)
r = (dpPos < 0) ? -radius : +radius // touch on the start's side (0x005381ea)
return (r - dpPos) / dpMove // UNCLAMPED — retail has no [0,1] clamp
```
Divergences fixed in acdream's port: the early-out was a precise-poly overlap
test at the CHECK position (wrong test, wrong endpoint); the touch side was
hard-coded `-radius`; the result was clamped to [0,1]. ACE's version keys the
± sign on `movement.LengthSquared() <= r²` — a misdecode of the `dpPos < 0`
compare.
## `BSPTREE::adjust_to_plane` — 0x00539bf0
Finds the closest non-penetrating position along `[curPos → checkPos]` and
commits it into the sphere. Two known-time bounds: `clearTime` (var_50/var_4c,
init 0.0 — the start is clear) and `hitTime` (var_48/var_44, init **1.0**
the check position hit). ONE iteration counter shared by both phases.
```
adjust_to_plane(this, CSphere* sphere /*in: at checkPos; out: adjusted*/,
Vector3 curPos, CPolygon*& hitPoly, Vector3* contactPt) -> success:
movement = sphere.center - curPos // (0x00539c16..0x00539c3c)
clearTime = 0.0 ; hitTime = 1.0 ; i = 0
// Phase 1 (0x00539c4d): walk plane-touch times.
loop:
t = hitPoly->adjust_sphere_to_poly(sphere, curPos, movement)
if t == 1.0: break // start within radius → pure [0,1] search
sphere.center = curPos + movement * t // (0x00539ca8-0x00539cb9)
if !root->sphere_intersects_poly(sphere, movement, &hitPoly, contactPt):
clearTime = t // touch position clear (0x00539d02)
break
// still blocked — by a possibly DIFFERENT poly (the tree test wrote hitPoly)
i += 1 ; hitTime = t // (0x00539cdd-0x00539ce5)
if i >= 15: return FAIL // the only failure exit (0x00539ce9)
// Phase 2 (0x00539ddb): binary-search [clearTime, hitTime]; i continues.
do:
avg = (clearTime + hitTime) * 0.5
sphere.center = curPos + movement * avg
if !sphere_intersects_poly(...): clearTime = avg
else: hitTime = avg
if hitTime - clearTime < 0.02: break // CONVERGED (0x00539dca) NOT a failure
i += 1
while i < 15
sphere.center = curPos + movement * clearTime // commit the last CLEAR time (0x00539de1)
return SUCCESS // Phase 2 always succeeds (even unconverged)
```
Caller (`collide_with_pt` 0x0053a090, PERFECT_CLIP branch): on success —
`set_collision_normal(globalized hitPoly.plane)`, `add_offset_to_check_pos(
globalize(adjusted original) · scale)`, return **Adjusted(3)**; on the rare
Phase-1 failure — return **Collided(2)** with NO normal write. acdream's
`CollideWithPt` already had this caller shape correct; only the two functions
above were broken.
## Verification
Headless replay `Issue180CorridorSweepHysteresisReplayTests` (real Facility
Hub cell 0x8A020164 BSP, the exact captured ray): pre-fix, targets embedded up
to 0.25 m into the wall behind the camera passed unclipped and the first
detection (s=1.625) stopped a full step back (eyeBack 1.354, tracking the
target); post-fix the stop must sit at the surface-contact point (eyeBack ≈
constant ≈ 1.61) for every target past first touch.

View file

@ -0,0 +1,268 @@
# Retail camera sought-position derivation — pseudocode (#180 fix)
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, PDB-named Binary Ninja decomp). Read 2026-07-06 for #180 (the
camera-sweep eye strobe = the #176 "stripes").
Functions covered (address = retail VA, line = pseudo-C line):
| Function | VA | pseudo-C line |
|---|---|---|
| `CameraManager::UpdateCamera` | `0x00456660` | 95505 |
| `SmartBox::PlayerPhysicsUpdatedCallback` | `0x00452d60` | 91836 |
| `SmartBox::set_viewer` | `0x00452c40` | 91780 |
| `SmartBox::update_viewer` (consumption; already ported) | `0x00453ce0` | 92761 |
| `CameraManager::QueryPivotPosition` | `0x004564f0` | 95448 |
| `Frame::interpolate_origin` | `0x005352f0` | 319607 |
| `Frame::interpolate_rotation` | `0x00535390` | 319618 |
| `Frame::close_rotation` | `0x00455d70` | 94838 |
| `CameraManager::CameraManager` (ctor defaults) | `0x00457090` | 95957 |
## 1. The state machine — three Positions, one loop
`SmartBox` holds **two viewer Positions**; `CameraManager` computes a third
(the desired pose) transiently each frame:
- **`viewer`** — the PUBLISHED camera pose. What the renderer/sound/sky use.
Written only by `set_viewer` (the swept result each frame).
- **`viewer_sought_position`** — the STATEFUL sweep target. Persisted across
frames. Converges from the current `viewer` toward the desired pose.
- **desired pose** (transient inside `UpdateCamera`) — pivot + heading-rotated
`viewer_offset`, the full-length ideal boom. Never swept directly.
Per-frame loop (order matters):
```
CPhysics::UseTime (0x00509900 loop, line 271640):
for each physics obj: CPhysicsObj::update_object(obj)
if obj == player:
SmartBox::PlayerPhysicsUpdatedCallback(smartbox) // 0x005099f2
→ sought = CameraManager::UpdateCamera(mgr, &viewer) // ← CURRENT swept viewer in
...later in the frame...
SmartBox::UseTime → SmartBox::update_viewer (called at 0x00454c34)
→ sweep viewer_sphere: pivot → sought
→ set_viewer(swept_result, reset_sought=0) // viewer = swept; sought UNTOUCHED
```
So each frame's sweep target derives from the PREVIOUS frame's collided
position. **The full-length ideal boom ray is never re-rolled per frame**
that is the #180 fix: after a wall contact the sought sits at (or a small step
past) the contact, and re-extends toward the ideal pose gradually.
## 2. `SmartBox::set_viewer(pos, reset_sought)` — 0x00452c40
```
set_viewer(this, pos, reset_sought):
this->viewer = pos // objcell_id + frame
if reset_sought != 0:
this->viewer_sought_position = pos // HARD RESET of the sought
this->viewer_cell = null
... (viewer light, sound listener, sky position, SceneTool::SetupCamera)
```
`reset_sought=1` call sites: teleport/hard resets, the `player->cell == 0`
bail in `update_viewer` (:92775), and the total sweep-failure fallback
(:92886). This is why retail's camera re-extends outward from your head after
a portal: sought = viewer = player position, then converges back out to the
boom.
## 3. `SmartBox::PlayerPhysicsUpdatedCallback` — 0x00452d60
```
PlayerPhysicsUpdatedCallback(this):
Position ret = CameraManager::UpdateCamera(this->camera_manager, &this->viewer)
this->viewer_sought_position = ret // objcell_id (0x00452d84) + frame (0x00452d87)
```
The argument is `&this->viewer` — the **current swept** viewer, not the
previous sought. The return becomes the new sought.
## 4. `CameraManager::UpdateCamera(this, ret, viewer)` — 0x00456660
Head (previously read): disabled bail, dt bookkeeping, manual offset-key
integration, pivot query. Tail (read this session): the desired pose + the
stateful interpolation. Full shape:
```
UpdateCamera(this, ret, viewer): // viewer = SmartBox::viewer (swept)
if !m_bEnabled: return viewer // 0x00456672 — pass-through
dt = Timer::cur_time - last_update_time // real seconds, float64
last_update_time = Timer::cur_time
// Manual camera-adjust keys integrate into the offsets (held-key rates):
if m_dwCameraOffsetMovement: viewer_offset += FlagsToVector(flags) * dt // 0x004566ca
if m_dwPivotOffsetMovement: pivot_offset += FlagsToVector(flags) * dt // 0x00456718
pivot_obj = CPhysicsObj::GetObjectA(pivot_object_id)
ok = pivot_obj != null && QueryPivotPosition(&pivotPos) // 0x004567f1
if !ok: return viewer // 0x00457050 tail — pass-through
// ── Everything below happens in the VIEWER's coordinate space ──
p = Position::localtoglobal(viewer, pivotPos) // pivot origin in viewer space (0x00456815)
look = (0,0,0) // the look-direction accumulator
if target_status & 2: // LOOK_AT_OBJECT (0x0045684a)
target = GetObjectA(target_object_id) // + optional part anchor
t = localtoglobal(viewer, targetPos, target_offset)
d = t - p
if normalize(d) ok: look += d
if m_bAlignCameraToSlope && (target_status & 0x10): // ALIGN_WITH_PLANE (0x00456928)
avgVel = 5-slot ring average of pivot velocity // 0x00456944..0x00456a5f
if normalize(avgVel) ok:
if |avgVel.x| < 2e-4 && |avgVel.y| < 2e-4: // near-vertical motion only
n = (transient_state & CONTACT) ? contact_plane.N
: (heading-of-avgVel or (0,0,1)) // 0x00456ab1
fwd = pivot's local +Y in world // localtoglobalvec (0x00456b90)
slopeFwd = fwd - n * dot(fwd, n) // project facing onto the plane
if normalize(slopeFwd) ok:
if target_status & 1: look += headingFrame(slopeFwd).rotate(direction)
else: look += slopeFwd
else if target_status & 1: look += pivot.rotate(direction)
else: // not moving →
if target_status & 1: // LOOK_IN_DIRECTION (0x00456dfa)
look += pivot.rotate(direction) // player facing ⊗ this->direction
if normalize(look) failed: // 0x00456ca4 — degenerate accumulator
look = pivot.rotate(direction) // fall back to the facing direction
// ── Desired pose: boom offset in the heading frame ──
Frame headingFrame // var_40
headingFrame.origin = p // pivot, viewer-space (0x00456cd7-0x00456cf1)
headingFrame.set_vector_heading(look) // 0x00456cf8
desiredEye = headingFrame.localtoglobal(viewer_offset) // pivot + R(look)·offset (0x00456d0d)
if target_status & 4: // LOOK_AT_PIVOT (0x00456d33)
look = p - desiredEye // re-aim from the DESIRED eye at the pivot
if normalize(look) failed: look = (0,0,0)
if normalize(look) failed: // 0x00456e59
look = pivot.rotate(direction)
Frame desired // var_c8
desired.origin = desiredEye
desired.set_vector_heading(look) // 0x00456e8c
// ── Stiffness → per-frame interpolation factors ──
tSnap = t_stiffness >= 1.0 - 2e-4 // 0x00456e9e
t = tSnap ? 1.0 : min(t_stiffness * dt * 10, 1.0) // 0x00456ec2
rSnap = r_stiffness >= 1.0 - 2e-4 // 0x00456ee1
r = rSnap ? 1.0 : min(r_stiffness * dt * 10, 1.0) // 0x00456f04
// ── THE STATEFUL STEP: interpolate FROM THE SWEPT VIEWER ──
Frame f // var_150
f.interpolate_origin(viewer.frame, desired, t) // lerp origins (0x00456fae)
f.interpolate_rotation(viewer.frame, desired, r) // slerp quaternions (0x00456fc8)
// ── Dead-band: park the sought ON the viewer when converged ──
if !tSnap: // 0x00456fe6
if distance(f.origin, viewer) < 4e-4 // 2 × 2e-4 m (0x00456fe1, 0x00456fed)
&& !rSnap
&& Frame::close_rotation(f, viewer.frame, 2e-4):
return viewer // EXACT copy — sought parks on the viewer
return Position{ objcell_id: viewer.objcell_id, frame: f } // 0x00457038-0x00457071
```
Ctor defaults (0x00457090): `t_stiffness = r_stiffness = 0.45`,
`viewer_offset = (0,-3,0)`, `direction = (0,1,0)`,
`target_status = LOOK_IN_DIRECTION`, `m_bAlignCameraToSlope = 1`,
`m_rCameraAdjustmentSpeed = 40`.
### Units & feel
`t = 0.45 · dt · 10 = 4.5·dt` → an exponential approach with rate constant
4.5/s (time constant ≈ 0.22 s: the sought covers ~63% of the remaining gap
every 0.22 s, frame-rate independently). The dead-band (0.4 mm translation +
2e-4 quaternion-component rotation) turns the asymptote into an exact fixed
point.
## 5. Consumption: `SmartBox::update_viewer` — 0x00453ce0 (already ported)
Already faithfully ported by `PhysicsCameraCollisionProbe.SweepEye`
(exonerated in the #180 investigation — see the handoff's DO-NOT-RETRY). For
completeness, the sought's role:
```
update_viewer(this):
if player.cell == null: set_viewer(player.pos, reset_sought=1); return // :92775
pivot = player/part frame origin + R·pivot_offset // :92815
startCell = indoor ? AdjustPosition-seat at pivot : player.cell // :92832
sweep viewer_sphere (r=0.3): pivot → viewer_sought_position, flags 0x5c // :92860-92868
if find_valid_position ok: set_viewer(swept.curr_pos, 0); viewer_cell = swept.curr_cell
else if AdjustPosition(sought) ok: set_viewer(sought_seated, 0)
else: set_viewer(player.pos, reset_sought=1); viewer_cell = null // :92886
```
Note both `reset_sought=1` sites map to acdream's
`CameraSweepResult.ViewerCellId == 0` returns in `SweepEye` (the cell-0 bail
and Fallback 2) — a clean 1:1 seam for the port.
## 6. Frame helpers
```
Frame::interpolate_origin(this, a, b, t): // 0x005352f0
this.origin = (1-t)·a.origin + t·b.origin // plain lerp
Frame::interpolate_rotation(this, a, b, t): // 0x00535390
d = dot(a.q, b.q)
if d < 0: b.q = -b.q; d = -d // shortest-path sign flip
if 1-d < 2e-4: w = (1-t, t) // near-identical nlerp weights
else: θ = acos(d); w = (sin((1-t)θ), sin(tθ)) / sin θ // slerp
this.q = w₀·a.q + w₁·b.q // (+ re-cache)
Frame::close_rotation(a, b, eps): // 0x00455d70
return |a.qw-b.qw| ≤ eps && |a.qx-b.qx| ≤ eps
&& |a.qy-b.qy| ≤ eps && |a.qz-b.qz| ≤ eps // component-wise, all four
```
## 7. Why this kills the #180 strobe (mechanism)
The probed failure: with the compressed boom moving along a wall, the
full-length pivot→ideal-eye ray grazes the corridor's double-faced slabs at
r±ε and has TWO first-contact solutions 0.27 m apart; mm-scale input drift
flips between them every few frames (`pulledIn` 0.27 ↔ 0.53).
With the retail sought: the target sits a single interpolation step past the
current contact — `α·|desiredviewer|` ≈ 0.7 mm at 1700 fps, ≈ 2 cm at 60 fps
(for the 0.27 m compression). The sweep ray extends only that far past the
wall, so even a bistable contact solution can only move the eye by the step
size, not 0.27 m. The strobe amplitude collapses from 0.27 m to sub-mm; the
eye glides along the wall following the player's drift. No damping of the
SWEPT result is involved — the published eye still hard-follows the sweep
every frame, exactly like retail.
## 8. Port mapping → `RetailChaseCamera`
| Retail | acdream |
|---|---|
| `viewer` (swept, published) | `_publishedEye` (+ `Position` property) |
| `viewer_sought_position` | `_soughtEye` (was `_dampedEye` — which wrongly lerped from ITSELF) |
| desired pose (transient) | `targetEye`/`targetForward` (existing boom math — unchanged) |
| `interpolate_origin(viewer→desired, t)` | `Vector3.Lerp(_publishedEye, targetEye, tAlpha)` |
| `interpolate_rotation(viewer→desired, r)` | forward-vector lerp (adaptation — register AD-37) |
| dead-band `return viewer` | `ApplyConvergenceSnap(_publishedEye, …)` parks sought on the viewer |
| `set_viewer(pos, 1)` resets sought | `swept.ViewerCellId == 0``_soughtEye = swept.Eye` |
| sweep pivot→sought | `SweepEye(pivotWorld, _soughtEye, …)` (was `_dampedEye` = full boom) |
Intentional adaptations (register rows added with the port commit):
- **AD-37** — rotation state is a forward VECTOR (nlerp + normalize, roll
always 0, up = world Z) instead of retail's quaternion slerp
(`interpolate_rotation` 0x00535390). Pre-existing shape of
`RetailChaseCamera`; the close_rotation dead-band test compares forward
vectors against the same 2e-4 epsilon.
- **AD-38** — first-frame init seats sought = viewer = the full-length target
eye; retail initializes via `set_viewer(player_pos, 1)` so the camera
re-extends from the player's head at login/teleport. acdream's teleports
don't reset the camera today; the pivot-anchored sweep self-heals within
one convergence (~1 s).
The previous in-code comment ("the collision must NOT feed back into the
damped state — retail keeps viewer_sought_position separate from viewer")
was WRONG about the direction of the coupling: retail's sought is derived
FROM the swept viewer every frame (0x00452d75); what stays clean is the
*desired pose*, which is recomputed transiently and never swept directly.
The old shape (lerp sought→ideal from itself + sweep the full boom) is what
made the knife-edge re-roll — and the #180 strobe — possible.

View file

@ -0,0 +1,75 @@
# `CTransition::calc_num_steps` + the viewer step loop — pseudocode (#181 equilibrium fix)
Source: Ghidra decompile (patchmem project, PDB-named) of `calc_num_steps`
(`0x0050a0b0`) and `find_transitional_position` (`0x0050bdf0`), read 2026-07-06
after the user's retail axiom ("retail camera is rock steady" pressed into
walls) contradicted acdream's measured wall-press wander (~0.5 mm/frame
forever, headless pin `Issue181WallPressEquilibriumTests`). The BN pseudo-C of
the same functions is x87 mush; Ghidra's is clean.
## `calc_num_steps` — 0x0050a0b0
```
calc_num_steps(this, out offset, out offsetPerStep, out numSteps):
if begin_pos == null: offset = 0; offsetPerStep = 0; numSteps = 1; return
offset = get_offset(begin_pos, end_pos)
r = local_sphere.radius
len = |offset|
if object_info.state & 4: # VIEWER (bit 2 of the 0x5c camera flags)
if len > F_EPSILON: # 2e-4
offsetPerStep = offset * (r / len) # EXACTLY radius-length steps, start-anchored
numSteps = floor(len / r) + 1 # the end lands INSIDE the last step
else:
offsetPerStep = 0; numSteps = 0 # zero-length path → the no-step success path
return
# non-viewer: equal slices of ~radius (TRANSITIONAL_PERCENT_OF_RADIUS = 1.0, 0x007c6874)
steps = len / (TRANSITIONAL_PERCENT_OF_RADIUS * r)
if steps > 1:
numSteps = ceil(steps); offsetPerStep = offset / numSteps
elif offset == 0:
offsetPerStep = 0; numSteps = 0
else:
offsetPerStep = offset; numSteps = 1 # short move → one whole step
```
## The step loop's viewer handling — `find_transitional_position` 0x0050bdf0
```
for i in 0 .. numSteps-1:
# VIEWER LAST-STEP REMAINDER: the final step covers exactly what's left,
# so the swept path ends exactly at end_pos (no overshoot):
if (state & 4) and i == numSteps-1 and len > F_EPSILON:
offsetPerStep = offset * ((len - (numSteps-1)*r) / len)
global_offset = adjust_offset(offsetPerStep)
# The negligible-offset abort is NON-VIEWER-ONLY — a pressed camera keeps
# stepping through sub-epsilon adjusted offsets:
if (state & 4) == 0 and |global_offset|² < F_EPSILON²: break
... check_pos += global_offset; transitional_insert(3); validate ...
if collision_normal_valid and (state & 8): break # PathClipped first-hit stop
```
## Why this stabilizes the wall-press equilibrium (#181)
acdream's pre-fix stepping for ALL movers was `numSteps = ceil(len/r)` equal
slices. The camera's convergence loop (sought = lerp(viewer→desired) → sweep →
viewer) perturbs `len` by mm every frame:
- equal slices: every step boundary shifts with `len`, and at `len` near an
exact multiple of r (the measured press pose: 1.5 m = 5 × 0.3 m) the step
COUNT flaps ceil-wise, teleporting the colliding step's window ~5 cm — the
clip's committed point jumps mm-scale, and the loop orbits a limit cycle
instead of a fixed point (the measured ~0.5 mm/frame wander → the #181
flicker excitation);
- retail viewer grid: the first `floor(len/r)` steps are constant radius-length
increments anchored at the pivot — invariant under mm target drift — and only
the remainder step breathes. The colliding step's window is stable, the clip
result is stable, the loop reaches retail's fixed point ("rock steady").
Port sites: `Transition.FindTransitionalPosition` (TransitionTypes.cs) — the
subdivision block + the per-step loop (last-step remainder + the abort gate).
Non-viewer behavior unchanged (already matches).

View file

@ -0,0 +1,228 @@
# Handoff: #184 Slice 2 — extract `RemotePhysicsUpdater` (2a) + unify the player/NPC remote fork (2b)
**Date:** 2026-07-07 · **Status:** ✅ DONE — 2a shipped `e1ac56cc` (byte-exact extract), 2b shipped
`ddb5a967` (fork collapse), **visual gate PASSED 2026-07-08 (user: "Looks good") → #184 FULLY CLOSED.**
A 3-lens adversarial review (workflow `wf_b163315b-14f`) corrected the plan below in one place: the
player gate is NOT "players de-overlap" — retail lets two non-PK players WALK THROUGH each other (PvP
exemption), so the remote-player mover was given `IsPlayer|EdgeSlide` (§4-adjacent). 2b's player win is
that players now collide with monsters + terrain + walls (Path A skipped all collision) while still
passing through each other. See the physics digest banner + ISSUES #184 for the shipped writeup.
**Read these first (in order):**
1. This handoff — the plan, the exact sites, the #40 verdict, the seams.
2. Physics digest banner (SSOT): `claude-memory/project_physics_collision_digest.md` — the #184
Slice 1 + Slice 3 banners (what shipped, the shadow-follows-resolved mechanism, the placement-snap).
3. Design spec: `docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md` §2.5 (the
original Slice 2/3 staging) + §5 (register bookkeeping — the #40 retirement).
4. Research behind this handoff: workflow `wf_c6a2e2b9-833` (3 read-only Opus agents: #40 risk /
extraction design / unification correctness). Findings distilled here.
---
## 0. Where #184 stands (what shipped, what this is)
The reported symptom — packed **monsters** interpenetrating (retail barely overlaps on the SAME ACE)
— is **FIXED and visually gated**. Two commits on `claude/vigorous-joliot-f0c3ad`:
- `37a94e1f`**Slice 1**: NPC `UpdatePosition` → retail `MoveOrTeleport` **placement-snap** +
grounded movement driven by the interp **catch-up** feeding the kept `ResolveWithTransition` sweep
+ **shadow-follows-resolved** (`SyncRemoteShadowToBody`, the load-bearing de-overlap persistence).
- `f51c1dff`**Slice 3**: the sweep's mover sphere is **Setup-derived** (`GetSetupCylinder` =
`setup.Radius`/`setup.Height` × ObjScale) so big/small monsters de-overlap at true radii.
Both gates passed (crowd de-overlap; large-monster spacing). Core 2621 / App 741 green.
`RemoteDeOverlapMechanismTests` (4 tests) proves the mechanism in Core.
**Slice 2 is the last piece — it is FAITHFULNESS + STRUCTURE, not a bug fix.** It does two things:
- **2a (behavior-neutral):** extract the per-remote dead-reckoning update out of the >10k-line
`GameWindow.cs` into a testable `RemotePhysicsUpdater` (Code Structure Rule 1).
- **2b (behavior change, GATED):** collapse the **two-path fork** — "Path A" (grounded PLAYER
remotes) currently OMITS the sweep; make it run the SAME catch-up+sweep+shadow-follows-resolved as
"Path B" (NPCs), so **packed players de-overlap too** and the "#40 remotes skip the transition"
adaptation is retired. The user-visible payoff is minor (players rarely pack); the real win is
retail-faithfulness (retail's `UpdateObjectInternal` has NO player/remote fork).
---
## 1. THE #40 VERDICT — DEAD (high confidence). 2b is SAFE. (do NOT re-investigate)
Path A skips the sweep because of a comment citing **issue #40** ("running our sweep on tiny
per-frame queue catch-up deltas amplifies micro-bounces into visible position blips — staircase +
flat-ground blips", `GameWindow.cs:10392-10399`). The research settled that #40 is a **stale
observation** and adding the sweep to Path A will NOT reintroduce it:
- **The decisive cross-check:** Path B (NPCs) ALREADY runs the EXACT #40-feared configuration in
production TODAY — clears `Body.Velocity`, drives translation purely from
`RemoteMotionCombiner.ComputeOffset` catch-up (the SAME driver Path A uses), and calls
`ResolveWithTransition` every tick on those tiny deltas. Path B's own comment says so:
*"Matches Path A's grounded model (:10113); clearing velocity mirrors Path A (:10125)"*
(`GameWindow.cs` ~:10489). It does NOT blip — #170/#171/#184 gates all passed.
- **Empirical proof in Core:** `RemoteDeOverlapMechanismTests.ConvergingCreatures_RealInterpLoop_DeOverlapsAndAbsorbsTheStallBlip`
drives the REAL `ComputeOffset → InterpolationManager.AdjustOffset` catch-up (incl. the
`fail_count>3` blip-to-tail) for 600 ticks and asserts every tick's net move stays < 0.30 m the
sweep absorbs the stall-blip.
- **#40 predates the sweep rebuild:** #40 closed **2026-05-05** (commit `40d88b9`), against the
ORIGINAL BSP-layer sweep. Every relevant sweep-internals rebuild landed AFTER: the CSphere
collision-family port (#182, `96ae2740`, 2026-07-07), the #137 corridor sliding-normal/phantom-wall
fixes, the L.2.3 step-height series, the #170/#171 chase/sticky work. #40 is against a sweep that
no longer exists.
- **The slope-staircase half is STRENGTHENED, not reintroduced:** Path A already samples
`terrainNormal` and passes it to `ComputeOffset` (the 2026-05-05 slope-staircase fix, `:10287`);
adding the sweep additionally provides retail terrain-Z snap along the slope.
**Cheapest way to confirm #40 is dead BEFORE the visual gate (do this in 2b):** extend
`RemoteDeOverlapMechanismTests` with a converging PLAYER-config pair (same `RealInterpLoop` shape,
player body/flags) and assert the same `maxSpike < 0.30`. If it passes, #40 is dead in code.
---
## 2. The plan — 2a FIRST (fork-preserving), then 2b (unify, gated)
**Order matters: 2a before 2b.** Do 2a as a strictly fork-preserving **pure refactor** (the Path A /
Path B fork stays inside the new class, byte-for-byte), verify suites green (behavior-neutral, NO
gate needed — the two passing gates remain a clean baseline), commit. THEN do 2b as a separate diff
that merges the fork inside the now-clean class, with its own test + review + visual gate. Combining
them would blow away the clean before/after baseline and make a gate failure unattributable.
⚠️ **2b is NOT a trivial "delete Path A."** Path A is *mostly* subtractive but has small
player-specific bits Path B lacks (see §4). Merging by editing Path B's shared logic risks
regressing NPCs — reconcile carefully.
---
## 3. Slice 2a — the extraction (behavior-neutral)
### 3.1 The seam (all line numbers are HEAD `f51c1dff`, current)
- **Extract:** the POSITION block of the `foreach (var kv in _animatedEntities)` loop in
`TickAnimations` (`GameWindow.cs`): from `uint serverGuid = ae.Entity.ServerGuid` (~:10152)
through the sticky-lease tail `rm.Host?.PositionManager.UseTime();` (~:10873), gated by
`if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid &&
_remoteDeadReckon.TryGetValue(serverGuid, out var rm) && rm.LastServerPosTime > 0)` (~:10167-10182).
- **DO NOT extract** the ANIMATION/RENDER half below ~:10876 (seqFrames/`Sequencer.Advance`,
`MotionDoneTarget` bind, `ConsumePendingHooks`, PARTSDIAG, MeshRef rebuild). It drives the
sequencer + hook router + rendering and must stay in `GameWindow`.
### 3.2 Signature + deps
- `RemotePhysicsUpdater.Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)`.
Per-call state is exactly `rm`, `ae`, `dt`; `serverGuid` derives from `ae.Entity.ServerGuid`;
`_liveCenterX/Y` are passed per-call (they change on streaming recenter — DO NOT snapshot them in
the constructor).
- **Constructor deps** (all pure services, no GL/render coupling): `PhysicsEngine _physicsEngine`,
`PhysicsDataCache _physicsDataCache`, and the live `IReadOnlyDictionary<uint, EntitySpawn>
_lastSpawnByGuid` (a shared reference GameWindow mutates; the updater only reads it, for
`GetSetupCylinder`).
- **Location:** `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (App-layer — it needs the App-owned
`RemoteMotion`/`AnimatedEntity` types and the helpers). Register the file if a new folder.
### 3.3 Helpers to move (with the block)
| Helper | Site | How it moves |
|---|---|---|
| `IsPlayerGuid` | `:1032` static `(guid & 0xFF000000u)==0x50000000u` | static on the updater (or a tiny shared static) |
| `ApplyPositionManagerDelta` | `:4607` static, pure (body+delta) | static on the updater |
| `TickRemoteMoveTo` | `:4864` body is just `rm.Movement.UseTime();` | inline into the updater |
| `GetSetupCylinder` | `:4572` deps `_physicsDataCache` + `_lastSpawnByGuid` | move to the updater (constructor deps) |
| `SyncRemoteShadowToBody` | `:4633` deps `_liveCenterX/Y` + `_physicsEngine.ShadowObjects` | move to the updater; take `liveCenterX/Y` as args (already the plan for `Tick`) |
| `ApplyServerControlledVelocityCycle` | `:5570` Path-B-only anim-cycle; Core calls only (`ae.Sequencer`, `IsPlayerGuid`, `ServerControlledLocomotion.PlanFromVelocity`) | move verbatim for behavior-neutral 2a (candidate to relocate to the anim half later) |
### 3.4 ⚠️ The one seam WRINKLE — the two helpers are ALSO called outside the DR loop
- `GetSetupCylinder` is also called by `StickToObjectFromWire` (~:4662) and other moveto/sticky
sites (`:4441-4442, :4492, :4662, :13820-13821, :13872`).
- `SyncRemoteShadowToBody` is also called by the NPC UP-branch tail in `OnLivePositionUpdated`
(~:6156, the Slice-1 first-UP/no-Sequencer shadow sync).
**Decision to make in 2a:** either (a) move both into `RemotePhysicsUpdater` and have `GameWindow`
call them back through the `_remotePhysicsUpdater` instance from those other sites (clean ownership,
but a general `GetSetupCylinder` living on the updater is a slightly odd home), OR (b) keep
`GetSetupCylinder` on `GameWindow` and pass it to the updater as a `Func` dep, moving only
`SyncRemoteShadowToBody`. **Recommendation:** (a) — `GameWindow` holds `_remotePhysicsUpdater` and
calls `_remotePhysicsUpdater.GetSetupCylinder(...)` / `.SyncRemoteShadowToBody(..., _liveCenterX,
_liveCenterY)` from the ~3 other sites. It's the cleanest single-owner.
### 3.5 2a acceptance
- `GameWindow.cs` shrinks by the position block (~700 lines) → `Tick(...)` call.
- Suites green (Core 2621 / App 741), NO behavior change, NO gate.
- The fork (`if (IsPlayerGuid(serverGuid) && !rm.Airborne)`) is PRESERVED inside the updater.
---
## 4. Slice 2b — unify the fork (behavior change, GATED)
### 4.1 The delta: make grounded PLAYER remotes run the sweep
Path A (grounded player remotes, `GameWindow.cs:10194-10429`) is Path B minus:
1. `ResolveWithTransition` (the sweep) — "Step 4b INTENTIONALLY OMITTED" (`:10392-10399`).
2. `SyncRemoteShadowToBody` (shadow-follows-resolved).
3. the `#173` velocity bounce + the airborne landing detection (both no-op for a grounded body).
But Path A ALSO has bits Path B does NOT — **these must survive the merge (do not regress players
OR NPCs):**
- **Omega fallback:** Path A applies `ObservedOmega ∥ seqOmega` (`:10351` — falls back to the
sequencer's synthesized omega when `ObservedOmega==0`). Path B applies `ObservedOmega` ONLY.
Reconcile so players keep the seqOmega fallback WITHOUT changing NPC omega behavior (do NOT just
bolt the fallback onto Path B's shared omega — verify against NPC turning first).
- **Compose fallback:** Path A has `else { rm.Body.Position += offset; }` when `rm.Host` is null
(`:10318-10321`). Path B's equivalent is `else if (!rm.Airborne) { rm.Body.Position += ComputeOffset(...); }`.
- **Diagnostics:** Path A's `[SLOPE]` (ACDREAM_SLOPE_DIAG) and `[OMEGA_DIAG]`/`[VEL_DIAG]`
(ACDREAM_REMOTE_VEL_DIAG) blocks — keep or fold as desired (diagnostic-only).
- Path B's grounded stale-velocity anim-stop is gated `!IsPlayerGuid` (`ApplyServerControlledVelocityCycle`)
— so it already no-ops for players; leave that gate.
**Cleanest merge (do it inside the extracted class):** one grounded path = `ComputeOffset` catch-up
(seed the sticky delta frame) → omega (`ObservedOmega ∥ seqOmega`) → `calc_acceleration` +
`UpdatePhysicsInternal` → the sweep → store resolved → `SyncRemoteShadowToBody``#173` bounce →
landing. Players and NPCs differ only in the `!IsPlayerGuid`-gated anim-cycle stop and the diagnostics.
### 4.2 ⚠️ THE COUPLED SHADOW-SYNC EDIT (research finding 9 — do NOT miss this)
Slice 1 made the per-UP raw-worldPos shadow sync in `OnLivePositionUpdated` (~:5699) **PLAYERS-ONLY**
(NPC shadows follow the resolved body via `SyncRemoteShadowToBody` in the tick). If 2b now gives
players the tick sweep + `SyncRemoteShadowToBody`, then the `:5699` players-only raw-pos sync becomes
the STALE one — packed players would de-overlap in-tick but re-snap-to-overlap once per UP. **Retire
/ unify `:5699`** so players also get resolved-body sync (mirror how Slice 1 handled NPCs: consider a
player UP-branch tail `SyncRemoteShadowToBody` for the first-UP / no-tick case, and remove or
airborne-gate the `:5699` raw sync). This edit is OUTSIDE the DR loop — that is exactly why 2b is its
own reviewed diff.
### 4.3 Register bookkeeping (in the 2b commit)
- **Retire the "#40 remotes skip the transition / server already collision-resolved" adaptation**
the register row + the `GameWindow.cs:10207-10208` premise (ISSUES.md #40 anchors ~:4536/:4551).
Cite that Path B already carries the sweep with passing gates.
- Any new adaptation the merge introduces (e.g. the omega-fallback reconciliation) gets its row.
- Move the AP-86 note about `:5699` being players-only if that changes.
### 4.4 2b test + gate
- **Test first:** the converging PLAYER-pair `RealInterpLoop` test (§1) — proves no #40 blip + players
de-overlap, in Core, before the gate.
- **Visual gate (the acceptance test):** (a) a player remote running up/down a hill — no slope
staircase; (b) two player remotes packed side-by-side on flat ground — steady-state XY unchanged
(no blip) + they de-overlap; (c) a regression pass on remote walk/run/jump/land/turn — UNCHANGED.
- Suites green (Core / App).
---
## 5. Preserve-list (regression watch — the frozen R4/R5 arc)
- The sweep + transition internals, the shadow registry, `CollisionExemption` — untouched.
- Sticky melee #171 (the `snapSuppressedByStick` gate + `StickyManager` overwrite of the seeded
frame) — survives; it's in Path B and the shared compose.
- The airborne remote path (already goes through Path B via `IsPlayerGuid && Airborne`) — 2b changes
only the GROUNDED player case; airborne is already swept.
- The omega integration (both `ObservedOmega` and the seqOmega fallback for players).
- The `node_fail_counter` blip watchdog.
- Slices 1 + 3 (shadow-follows-resolved, placement-snap, Setup-derived sphere) — the base; unchanged.
- The `#42` self-collision self-skip (`movingEntityId: kv.Key`) — confirm it's passed on the unified
player path (Path A currently does NOT call the sweep, so this is new for players).
## 6. Pointers
- Research: workflow `wf_c6a2e2b9-833` (this session) — the #40 verdict + extraction design +
unification correctness, with file:line evidence.
- Slices 1/3 commits: `37a94e1f`, `f51c1dff`. The #184 arc plan/design:
`docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`;
`docs/research/2026-07-07-remote-creature-deoverlap-handoff.md`.
- Digest (SSOT + the failed-attempt lesson): `claude-memory/project_physics_collision_digest.md`.
- Tests: `tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs` (extend with the
player-config `RealInterpLoop`).
- The register: `docs/architecture/retail-divergence-register.md` (retire #40; AP-86/AP-87 are the
#184 rows; TS-46 is the sphere-scalar residual).

View file

@ -0,0 +1,266 @@
# CSphere collision family — retail pseudocode (port prep)
**Date:** 2026-07-07 · **Trigger:** the user's live report — *"in a packed
crowd of monsters it's too easy to get stuck; you can't wiggle free. In
retail the same crowd leaves a bit more room to shuffle/slide out."*
Humanoid creatures/players collide as **body spheres** (`Setup.Spheres`,
no CylSpheres → `ShadowShapeBuilder.FromSetup` emits `ShadowCollisionType.Sphere`),
so the crowd contact runs through `Transition.SphereCollision` — which was a
**hand-rolled 3-D wall-slide with a forced fixed de-penetration**, NOT a port
of the retail `CSphere::intersects_sphere` family. Register row **TS-45**
already documented this divergence and predicted the exact symptom
(*"absorbs to a zero offset … until an oblique input clears it"*); it was
deferred to avoid disturbing the freshly-gated #171 sticky-melee work.
This is the **direct analog of the 2026-07-05 CCylSphere family port**
(`docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md`). Same
shape, same siblings, same ACE-as-readable-oracle method. The transition loop
(`transitional_insert`) is **unchanged** — only the per-contact Sphere
response is replaced.
**Sources:** named-retail pseudo-C (addresses below) = ground truth;
`references/ACE/Source/ACE.Server/Physics/Sphere.cs` = readable cross-reference
(settles the x87 garbles). One ACE quirk noted in §8.
## Retail function inventory
| Function | Address | pseudo-C line |
|---|---|---|
| `CSphere::intersects_sphere(Position*, scale, CTransition*, isCreature)` — wrapper | `0x00537fd0` | :321881 |
| `CSphere::intersects_sphere(CTransition*, isCreature)` — dispatcher | `0x00537a80` | :321678 |
| `CSphere::collides_with_sphere(disp, radsum)` | `0x005369e0` | :320964 |
| `CSphere::step_sphere_up` | `0x00537900` | :321611 |
| `CSphere::slide_sphere(sp, ci, normal, currCenter)` — core crease slide | `0x00537440` | :321403 |
| `CSphere::land_on_sphere` | `0x005379a0` | :321642 |
| `CSphere::collide_with_point` | `0x00537230` | :321327 |
| `CSphere::step_sphere_down` | `0x00536d20` | :321133 |
**Dispatch context** — `CPhysicsObj::FindObjCollisions` (`0x0050f050`, pc:276776):
`isCreature = (target.state & MISSILE_PS 0x40) || target.IsCreature()`
(pc:276846-276853; ACE `PhysicsObj.cs:409`). The target's shape is chosen
CylSphere-first, else Sphere (pc:276868/276917; ACE `PhysicsObj.cs:414-436`).
Humanoids have `GetNumCylsphere()==0` + `GetNumSphere()>0` → **the sphere
branch**, `isCreature` passed in.
## 1. collides_with_sphere (0x005369e0) — pure 3-D overlap
```
collides_with_sphere(disp, radsum): // disp = globalSphere.center this.center
return |disp|² <= radsum² // full 3-D distance (unlike CCylSphere's XY+Z-band)
```
`radsum` at every call site = `globalSphere.radius + this.radius F_EPSILON`
(ε shaved ONCE in the dispatcher preamble, 0x00537acd). The ε is what makes
*resting exactly touching* a non-overlap, so a shuffle that ends flush against
a monster settles instead of re-colliding every frame. **The hand-rolled
version omitted this ε — the first stickiness source.**
## 2. Dispatcher (0x00537a80)
```
intersects_sphere(this, CTransition* t, isCreature): // this.center in world frame
sp = t.sphere_path; oi = t.object_info
s0 = sp.global_sphere[0]; disp0 = s0.center this.center
if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center this.center
radsum = s0.radius + this.radius F_EPSILON
// ── branch 1: placement / ethereal — detection only ──
if (sp.obstruction_ethereal || sp.insert_type == PLACEMENT_INSERT):
if collides(disp0) → COLLIDED
if num_sphere>1 && collides(disp1) → COLLIDED
return OK
// ── branch 2: step-down probe — land on the top ──
if (sp.step_down):
if isCreature → OK // §8.1 — you never stand ON a creature/missile
return step_sphere_down(this, s0, disp0, radsum)
// ── branch 3: walkable probe — occupancy blocks ──
if (sp.check_walkable):
if collides(disp0) → COLLIDED
if num_sphere>1 && collides(disp1) → COLLIDED
return OK
// ── branch 4: normal sweep (collide flag clear) ──
if (!sp.collide):
if (oi.state & (CONTACT|ON_WALKABLE)): // grounded
if collides(disp0) → step_sphere_up(this, disp0, radsum)
if num_sphere>1 && collides(disp1) → slide_sphere(this, disp1, sphereNum=1)
elif (oi.state & PATH_CLIPPED):
if collides(disp0) → collide_with_point(this, s0, radsum, 0)
else: // airborne
if collides(disp0) → land_on_sphere(this)
if num_sphere>1 && collides(disp1) → collide_with_point(this, s1, radsum, 1)
return OK
// ── branch 5: collide-flag re-test — exact-TOI cap landing ──
if isCreature → OK // §8.1 — never land ON a creature/missile
if !collides(disp0) && !(num_sphere>1 && collides(disp1)) → OK
movement = sp.global_curr_center[0] s0.center block_offset(cur→check)
radsum += F_EPSILON
lenSq = |movement|²
diff = dot(movement, disp0)
if |lenSq| < F_EPSILON COLLIDED
t = sqrt(diff² (|disp0|² radsum²)·lenSq) + diff // quadratic solve for TOI
if t > 1: t = diff·2 t
time = t / lenSq
timecheck = (1 time)·sp.walk_interp
if timecheck >= sp.walk_interp || timecheck < 0.1 → COLLIDED
movement *= time
disp0 = (disp0 + movement) / radsum
if !is_walkable_allowable(disp0.z) → OK // too steep to rest on
contactPt = s0.center disp0·s0.radius
ci.set_contact_plane(Plane(n=disp0, d=dot(disp0, contactPt)), is_water=1)
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = timecheck
sp.add_offset_to_check_pos(movement, s0.radius)
return ADJUSTED
```
State bits (verified against our `ObjectInfoState`): CONTACT=0x1,
ON_WALKABLE=0x2, PATH_CLIPPED=0x8. Note branch 5's landing surface is a
**tilted** plane (`n = disp0`, the sphere-to-sphere direction) — unlike the
cylinder's flat top — because a sphere top is curved.
## 3. step_sphere_up (0x00537900)
```
step_sphere_up(this, disp0, radsum):
radsum += F_EPSILON
if (oi.step_up_height < radsum disp0.z) // too tall to step over
→ slide_sphere(this, disp0, 0)
n = sp.global_curr_center[0] this.center
if CTransition::step_up(t, n) → OK
else → sp.step_up_slide(t)
```
For a full-height creature the head clearance `radsum disp0.z` far exceeds
`step_up_height` (~0.4 m) → always `slide_sphere`. This is the grounded-crowd
path: **slide, don't stop.**
## 4. slide_sphere (grounded) — the wiggle-room primitive
The grounded foot/head hit reduces to `CSphere::slide_sphere` (0x00537440),
which acdream **already has** as `Transition.SlideSphere(normal, currPos,
sphereNum)` (Ghidra-confirmed via #116). It computes the collision normal
`n = global_curr_center[sphereNum] this.center`, normalizes, then projects
the displacement onto the **crease** = `cross(n, contactPlane.normal)` — the
tangent where the monster's side meets the ground — and slides ALONG it.
Returns SLID (or COLLIDED when the projected offset degenerates < ~1.41 cm).
**This is the whole fix.** The hand-rolled version projected onto the plane
⊥ the raw 3-D normal and then FORCE-pushed to a fixed `combinedR + 0.01 m`
shell (radial de-penetration). Radial pushes from neighbours on opposite
sides cancel/fight; the crease slide moves you tangentially toward the gap.
```
slide_sphere(this, disp, sphereNum):
n = sp.global_curr_center[sphereNum] this.center
if normalize_check_small(n) → COLLIDED
return SlideSphere(n, sp.global_curr_center[sphereNum], sphereNum) // shared crease slide
```
## 5. land_on_sphere (0x005379a0) — airborne foot hit
```
land_on_sphere(this):
n = sp.global_curr_center[0] this.center
if normalize_check_small(n) → COLLIDED
sp.set_collide(n) // backup + Collide flag → next attempt runs branch 5
sp.walkable_allowance = LANDING_Z (0.0871557)
return ADJUSTED
```
Identical shape to `CCylSphere::land_on_cylinder`.
## 6. collide_with_point (0x00537230) — PathClipped / airborne head hits
```
collide_with_point(this, checkSphere, radsum, sphereNum):
globalOffset = sp.global_curr_center[sphereNum] this.center
if !PerfectClip:
if !normalize_check_small(globalOffset): ci.set_collision_normal(globalOffset)
return COLLIDED
// PerfectClip → exact TOI reposition (missiles only; §8.2)
blockOffset = get_block_offset(cur, check)
checkOffset = checkSphere.center global_curr_center[sphereNum] + blockOffset
collisionTime = find_time_of_collision(checkOffset, globalOffset, radsum + F_EPSILON)
if collisionTime < F_EPSILON || collisionTime > 1 → COLLIDED
collisionOffset = checkOffset·collisionTime checkOffset
old_disp = collisionOffset + checkSphere.center this.center
ci.set_collision_normal(old_disp / radsum)
sp.add_offset_to_check_pos(old_disp, checkSphere.radius)
return ADJUSTED
```
Non-PerfectClip = `set_collision_normal` + COLLIDED (decomp-verified). The
PerfectClip TOI tail is dead code in M1.5 (no mover sets PerfectClip) — same
status as the cylinder family's AP-83.
## 7. step_sphere_down (0x00536d20) — land on the top during a step-down probe
```
step_sphere_down(this, s0, disp0, radsum):
if !collides(disp0) && !(num_sphere>1 && collides(disp1)) → OK
stepDown = sp.step_down_amt · sp.walk_interp
if |stepDown| < F_EPSILON COLLIDED
radsum += F_EPSILON
val = sqrt(radsum² (disp0.x² + disp0.y²)) // curved-top height at this XY
scaledStep = (val disp0.z) / stepDown
timecheck = (1 scaledStep)·sp.walk_interp
if timecheck >= sp.walk_interp || timecheck < 0.1 → COLLIDED
interp = stepDown · scaledStep
disp0 = (disp0.x, disp0.y, disp0.z + interp) / radsum
if disp0.z <= sp.walkable_allowance → OK // too steep — sphere top is a dome
contactPt = disp0·this.radius + this.center
ci.set_contact_plane(Plane(n=disp0, d=dot(disp0, contactPt)), is_water=1)
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = timecheck
sp.add_offset_to_check_pos((0,0,interp), checkSphere.radius)
return ADJUSTED
```
Gated OFF for creatures at the dispatcher (branch 2: `isCreature → OK`), so in
M1.5 this only lands the player on non-creature Sphere-shape statics. Ported
for completeness/faithfulness (ACE `Sphere.cs:617`).
## 8. Divergences + settled ambiguities (register-relevant)
1. **`isCreature` gates "stand-on/land-on" (branches 2 and 5).** A creature or
missile is a solid you push against horizontally but never rest on
vertically. Retail short-circuits both to OK for `isCreature` (0x00537b8c
/ 0x00537af5). Port verbatim.
2. **PerfectClip TOI tail is dead in M1.5** — no mover arms PerfectClip
(players never do). The load-bearing path is non-PerfectClip
`set_collision_normal + COLLIDED` (decomp-verified). Same status/row style
as the cylinder family's **AP-83**; re-decompile 0x00537230 in Ghidra
before shipping missiles (F.3).
3. **`is_water=1` on contact planes is RETAIL** (0x00536ecf / branch-5 site;
`set_contact_plane` arg3 → `contact_plane_is_water`). Port verbatim; do
not "fix" — same as the cylinder family §8.1.
4. **Block offset = 0** in branch 5 / collide_with_point: retail subtracts the
cur→check landblock offset; acdream's physics frame is continuous
world-space → 0. Standing adaptation (same note as `SlideSphere`'s gDelta
and the cylinder family §8.3).
5. **ACE bug NOT copied:** ACE `Sphere.cs` has no equivalent of the cylinder
family's foot-vs-head disp mixup; the grounded head slide passes `globSphere_`
(the head sphere) at `IntersectsSphere:348` — correct. Retail 0x00537ec4
agrees. No divergence here.
6. **`normalize_check_small`** = normalize; returns true (fail) when |v| <
F_EPSILON — maps to our existing `NormalizeCheckSmall`.
## 9. acdream port surface
`Transition.SphereCollision(ShadowEntry, SpherePath, bool isCreature)` becomes
the branch-2/3/4/5 dispatcher body (branch 1 ethereal keeps the existing
early-OK consume + the caller's Layer-2 override, identical to the cylinder
family). New private siblings mirror the `Cyl*` set:
`SphereCollidesWithSphere`, `SphereStepSphereUp`, `SphereSlideSphere`,
`SphereLandOnSphere`, `SphereCollideWithPoint`, `SphereStepSphereDown`.
Reused as-is: the shared `SlideSphere` (crease projection), `DoStepUp`
(= CTransition::step_up), `SpherePath.StepUpSlide`, `NormalizeCheckSmall`,
`CollisionInfo.SetContactPlane/SetCollisionNormal`. The caller
(`FindObjCollisionsInCell`, Sphere branch) computes
`isCreature = (obj.State & 0x40) != 0 || (obj.Flags & IsCreature) != 0` and
passes it in. The `BspOnlyDispatch` gate and the ethereal step-down skip sit
ABOVE this dispatch and are unaffected. **Retires TS-45.**

View file

@ -0,0 +1,243 @@
# Handoff: verbatim port of retail's per-remote physics tick so packed monsters de-overlap
**Date:** 2026-07-07 · **Status:** DESIGN SPEC WRITTEN + APPROVED, IMPLEMENTATION NOT STARTED
(fresh session). **Driving report (user, side-by-side vs retail on the SAME ACE):** monsters
packed around the player **overlap** (arms interpenetrating) in acdream; in the retail client they
barely overlap. That overlap is why there's "no room to slide out" of a crowd.
**Read these first (in order):**
1. Design spec: [`docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`](../superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md) — the full design.
2. This handoff — the executable detail + the exact verified code sites + what to preserve.
3. Physics digest banner (SSOT): `claude-memory/project_physics_collision_digest.md` (2026-07-07,
incl. the "the room is SLIDING, user RETRACTED the jiggle framing" note).
---
## 0. Session context — what shipped, what this is
This work is a **new, distinct thread** from the #182 *player* physics rebuild that shipped THIS
session (6 commits `8bb8b204``e3c4c59b`, Core 2617 / App 741 green, airborne-stuck **confirmed
fixed by the user's visual gate**: "Works better, don't get stuck in the animation"). That rebuild
is landed — do NOT redo it. Its own residual (the grounded ground-jam, #137 family) is a separate
Slice 3 tracked in ISSUES #182.
During that gate the user noticed the crowd still feels wrong and diagnosed it side-by-side vs
retail: **the monsters are packed tighter / overlapping in acdream**. That's THIS handoff.
---
## 1. Verified root cause (decomp + source — do NOT re-derive)
Research workflow `wf_d2ff782f-9cb` (4 read-only agents, Opus) + my own direct source reads. Retail
`pc:`/`0x…` anchors are into `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**Retail runs ONE unified per-object tick and collides every creature locally:**
- `CPhysics::UseTime` (**0x00509950**, pc:271640) walks **every** `CPhysicsObj` in the object table
and runs `update_object → UpdateObjectInternal` on each — **no fork** player-vs-remote. The player
only gets an extra camera callback.
- `UpdateObjectInternal` (**0x005156b0**) runs the full `transition` (collision) for any active
object with spheres whose origin moved. `CObjCell::find_obj_collisions` (**0x0052b750**, pc:308916)
iterates the cell's shadow list; creature-vs-creature collision is exempt **only** for a *viewer*
or *IGNORE_CREATURES* mover. So **moving remote creatures de-overlap against each other, client-side.**
- A remote stays "active" via the motion system feeding velocity each tick (`apply_interpreted_movement`
0x00528600 → `set_local_velocity` 0x005114d0 → `set_velocity` 0x005113f0 sets the 0x80 active bit).
**Retail's server position is a GENTLE dead-reckoning TARGET, not a hard-snap:**
- `SmartBox::HandleReceivedPosition` (**0x00453fd0**) → `CPhysicsObj::MoveOrTeleport` (**0x00516330**):
teleport-TS / no-cell → hard snap; has-velocity AND `player_distance < 96`**`InterpolateTo`**
(queue a DR node); has-velocity AND `≥ 96` → far snap.
- `InterpolationManager::adjust_offset` (**0x00555d30**) catches up toward the queued position at
**≤ 2× the creature's max animation speed × dt**. The per-tick **collision-resolved** position is
stored (`SetPositionInternal` writes `sphere_path.curr_pos`, NOT the raw target) and **persists**;
the next server update re-aims the catch-up **from wherever collision left the creature**. A
`node_fail_counter` blips to the server point only after ~5 genuinely-stuck frames.
**So retail's de-overlap is PROACTIVE (movement collision-stopped before interpenetration) and
PERSISTENT (never snapped-into-overlap).** ACE broadcasts each creature's own overlapping pathed
position; the CLIENT spreads them. (ACE/holtburger source are NOT checked out — empty submodule
dirs; the decomp + the #182 live cdb trace settle client-responsibility.)
**acdream's two divergences (both verified against source):**
1. **NPC (monster) `UpdatePosition` HARD-SNAPS** to the raw server position — `GameWindow.cs:5925-5926`:
`if (!snapSuppressedByStick) rmState.Body.Position = worldPos;` (suppressed only while a sticky
melee lease is armed). A packed monster teleports into the overlapping server position every UP,
**overwriting** the swept de-penetration. The sweep resolves *movement*, not a static overlap it
gets snapped into.
2. **A two-path fork retail doesn't have** (`GameWindow.cs:10076`):
- **Path A** — grounded *player* remotes (`IsPlayerGuid && !Airborne`, `:10076-10311`): advance
via `RemoteMotionCombiner.ComputeOffset` interp catch-up, **`ResolveWithTransition` NOT called**
(`:10274-10281` "collision is the sender's problem" — factually wrong per the decomp).
- **Path B** — NPCs/monsters + airborne player remotes (`:10312-10698`): **DO** call
`ResolveWithTransition` (`:10558`, `moverFlags: EdgeSlide`, self-skip `movingEntityId: kv.Key`)
— so a monster already collides against other creatures. But it drives the body from
`get_state_velocity()` (synth locomotion), and the NPC hard-snap (#1) overwrites the result.
**KEY INSIGHT: the collision math already exists and is faithful.** The bug is the **reconciliation
(hard-snap)** + the **movement model (synth-velocity instead of catch-up)** + the **fork**.
---
## 2. SCOPE REFINEMENT — read this before planning (discovered reading Path B)
The fix is **bigger than "replace the hard-snap."** Path B drives the body from
`get_state_velocity()` (the animation locomotion velocity), NOT the interp catch-up toward the
server target. For a monster to de-overlap, its per-tick **movement must chase the server target**
(so the sweep resolves *that* movement against neighbors and stops it at contact). So the faithful
fix **reworks Path B's movement model**: `synth-velocity → interp catch-up (ComputeOffset, like
Path A) → sweep → store resolved`. Retail's `UpdatePositionInternal` uses a **REPLACE dichotomy**
(the catch-up REPLACES the anim root motion when the queue is active; the anim just animates the
legs). This is a **delicate change to the FROZEN R4/R5 remote-DR arc** — treat it with care.
---
## 3. Exact code sites (all verified against source THIS session)
`RemoteMotion` type — `GameWindow.cs:441`: `.Body` (`:443` the sim PhysicsBody), `.Movement` (`:453`
R5 MovementManager; `.Motion => Movement.Minterp` `:455`, `.MoveTo` `:478`), `.Host` (`:485`
EntityPhysicsHost → TargetManager + the faithful `Motion.PositionManager` Sticky/Constraint facade),
`.Interp` (`:581` the `InterpolationManager` catch-up queue), `.Position` (`:590` the
`RemoteMotionCombiner`, NOT the retail facade).
**A. Remote `UpdatePosition` handler (`OnLivePositionUpdated`), `GameWindow.cs:~5640-5980`:**
- `:5655` `entity.SetPosition(worldPos)` (unconditional render snap, undone later per-branch).
- `:5665-5672` `ShadowObjects.UpdatePosition(entity.Id, worldPos, …)`**keep** (server-truth
shadow sync; retail `change_cell`/`AddShadowObject`). NOTE: this syncs the *collision broadphase*
target to server truth even though the rendered body de-overlaps — verify this doesn't re-pack.
- `:5724` `if (IsPlayerGuid(update.Guid))` → **the player-remote branch ALREADY implements retail
MoveOrTeleport** (airborne no-op `:5776`; landing `:5791`; far>96 snap+clear `:5827-5832`; near
`Interp.Enqueue(worldPos, heading, isMovingTo:false, currentBodyPosition:…)` `:5843-5847`). **This
is the model to extend to NPCs.**
- The `else` (NPC) branch, `:5879-5975`: computes `ServerVelocity` synth (`:5863`/`:5889`, keep for
`[VEL_DIAG]`); **`:5925-5926` the HARD-SNAP** `rmState.Body.Position = worldPos` (suppressed while
`snapSuppressedByStick`); `:5952` `rmState.CellId = p.LandblockId`; `:5967+` orientation hard-snap
(also stick-gated). **THIS is what Slice 1 replaces with the MoveOrTeleport Enqueue.**
**B. Path B tick (NPC/legacy), `GameWindow.cs:~10312-10698`:**
- `:10457-10460` `rm.Body.set_local_velocity(rm.Motion.get_state_velocity(), …)` when OnWalkable —
**the synth-velocity movement source to REPLACE with the interp catch-up.**
- `:10484-10492` manual omega/orientation integration (ObservedOmega) — **keep**.
- `:10512` `preIntegratePos = rm.Body.Position`.
- `:10521-10526` sticky delta via `rm.Host.PositionManager.AdjustOffset(pmDelta, dt)` +
`ApplyPositionManagerDelta`**keep, composes with the catch-up (retail adjust_offset order)**.
- `:10527-10528` `calc_acceleration()` + `UpdatePhysicsInternal(dt)` (integrates the synth velocity).
- `:10549-10583` **the SWEEP** `ResolveWithTransition(preIntegratePos, postIntegratePos, rm.CellId,
0.48f, 1.835f, 0.4f, 0.4f, isOnGround:!rm.Airborne, body:rm.Body, moverFlags:EdgeSlide,
movingEntityId:kv.Key)` — **KEEP; this is the creature-vs-creature de-overlap** (Slice 3 makes the
0.48/1.835 Setup-derived).
- `:10585` `rm.Body.Position = resolveResult.Position` + `:10586-10587` CellId writeback.
- `:10606+` the #173 airborne reflect (old AD-25 airborne-only bounce) — leave for now (or later
fold into `PhysicsObjUpdate.HandleAllCollisions` like the player path; NOT Slice 1).
**C. Path A tick (player remote, ComputeOffset, NO sweep), `GameWindow.cs:10076-10311`:**
- `:10173` `rm.Position.ComputeOffset(dt, rm.Body.Position, seqVel, ori, rm.Interp, maxSpeed,
terrainNormal)` — the catch-up model (REPLACE dichotomy). `:10274-10281` the "ResolveWithTransition
NOT called" opt-out. **Slice 2 adds the sweep here and unifies with Path B.**
**D. `RemoteMotionCombiner.ComputeOffset`, `RemoteMotionCombiner.cs:59-115`:** interp catch-up
(`interp.AdjustOffset` toward the queue head) REPLACES the frame when non-zero; else seqVel anim
fallback projected on the terrain plane. This is the movement primitive both paths should use.
**E. Substrate (KEEP, already retail-shaped):** shadow registry `RegisterLiveEntityCollision`
`GameWindow.cs:4171` (`:4276 RegisterMultiPart`, `IsCreature` `:4271`); `CollisionExemption`
`CollisionExemption.cs:59-129` (creature-vs-creature exempt only for viewer/ignore-creatures mover —
an NPC mover with `EdgeSlide` is NOT exempt, so it DOES collide with other creatures).
---
## 4. The plan (slices) — see §2.2/§2.5 of the design spec
**Slice 1 — NPC de-overlap (the reported symptom). The two changes are COUPLED, land together:**
1. **NPC UP handler** (`:5925-5926` + orientation `:5967+`): replace the hard-snap with the retail
`MoveOrTeleport` routing — mirror the player-remote branch (`:5822-5848`): far>96 → snap + clear
queue; near → `Interp.Enqueue`; teleport/parent → snap. **Keep** the `snapSuppressedByStick`
escape (it's retail's "armed stick owns the frame, server correction can't fight it", TS-41/44).
Keep the orientation hard-snap (retail hard-snaps orientation on UP).
2. **Path B tick** (`:10457-10528`): replace the `get_state_velocity` movement with the interp
catch-up as the movement source (use `rm.Position.ComputeOffset` / `rm.Interp.AdjustOffset` toward
the queued server target, REPLACE dichotomy), compose the sticky delta, **KEEP the sweep** (`:10558`)
and store `resolveResult.Position`. The anim (legs) still plays via the sequencer. `ServerVelocity`
stays for diagnostics only.
- **Gate:** monsters de-overlap side-by-side vs retail; no remote jitter/rubber-band/desync; sticky
melee (#171) unbroken; walk/run/jump/land unchanged; Core 2617 / App 741 green.
**Slice 2 — unify the fork:** collapse Path A into the same catch-up+sweep model (player remotes gain
the sweep → packed *players* de-overlap too). Per Code Structure Rule 1 (no new feature body in the
>10k-line `GameWindow`), extract a **`RemotePhysicsUpdater`** class (`src/AcDream.App/Physics/` or
`Rendering/`) owning the unified per-remote `UpdateObjectInternal` chain (interp delta → sweep →
commit); `GameWindow`'s loop shrinks to `RemotePhysicsUpdater.Tick(rm, dt)`. Extract the
`MoveOrTeleport` routing into a shared method so NPC + player-remote reconciliation is ONE impl.
Retire the "remotes skip the transition" adaptation (`:10089`/`:10275`, filed #40, ISSUES.md ~:4899).
**Slice 3 — Setup-derived mover sphere:** replace the hard-coded `0.48f/1.835f` (`:10551-10556`) with
the creature's Setup sphere / `ObjScale` so differently-sized creatures de-overlap at true radii.
---
## 5. What to PRESERVE (regression watch — the R4/R5 arc has many hard-won fixes)
- The **sweep + transition internals** (`ResolveWithTransition` and below), **shadow registry**,
**`CollisionExemption`** — untouched (already faithful).
- The **R5 managers** (`MovementManager`/`MoveToManager`/`TargetManager` + `Motion.PositionManager`
Sticky/Constraint) — TICKED by the unified update, not rewritten.
- **Sticky melee #171** (`snapSuppressedByStick` escape + the sticky delta at `:10521-10526`) — MUST
survive; it's the "monster facing while attacking" fix.
- The **airborne remote path** (`!IsGrounded` no-op `:5776`, landing transition `:5791`, gravity
integration between UPs) — MUST survive.
- The **omega/orientation manual integration** (`:10484-10492`) and orientation hard-snap — keep.
- The **`node_fail_counter` blip watchdog** in `InterpolationManager` — keep (retail's stuck→snap
escape; without it a genuinely-blocked remote freezes).
- The **#182 local-player rebuild** (this session) — orthogonal, untouched.
## 6. Gotchas
- **Don't double-move:** the catch-up REPLACES the synth-velocity (retail's REPLACE dichotomy), it is
NOT additive on top of `get_state_velocity`.
- **The two Slice-1 changes are coupled** — Enqueue is useless unless Path B consumes the queue; a
half-change (Enqueue without the catch-up movement) breaks DR (body stops tracking the server).
- **`ShadowObjects.UpdatePosition` (`:5665`) still syncs the broadphase to server truth** — verify
the de-overlapped *rendered* body and the server-truth *collision target* don't fight (retail's
shadow follows the collided position; check whether acdream should sync the shadow to the
collision-resolved position instead of raw server truth). Potential subtle bug — probe it.
- **The sweep only de-overlaps MOVING creatures** (it resolves movement, not static overlap). Retail
never lets deep static overlap form (proactive). The converging-pair test must drive real movement.
## 7. Validation
- **Conformance test** (`RemotePhysicsUpdaterTests`, easiest after the Slice-2 extraction): two
registered creature shadows given converging catch-up targets → assert they settle at
contact-distance (sum of radii), not overlapping; a `MoveOrTeleport` routing test (near→enqueue,
far→snap, teleport→snap); a persistence test (a de-penetrated position survives the next server UP).
- **Suites green:** Core 2617 / App 741 (no regression to remote anim, DR smoothness, sticky #171,
the #182 player fix).
- **Visual gate (acceptance):** side-by-side vs the retail client on the SAME ACE — packed monsters
spread to retail spacing (arms no longer interpenetrating); remotes don't jitter/rubber-band/desync;
sticky-melee facing (#171) unbroken; remote walk/run/jump/land unchanged.
- **Apparatus:** `ACDREAM_REMOTE_VEL_DIAG=1` (existing, UP/seq pace); add a `[remote-deoverlap]` probe
(rendered pos vs raw server UP + neighbor overlap depth) if a residual needs it.
## 8. Risk
Touches the **frozen R4/R5 remote-DR arc**. The synth-velocity model was tuned against live ACE pace
over the R4/R5 sessions; replacing it with the catch-up model risks regressing remote walk/run
smoothness, the slope "staircase" fix, and the sticky melee. Mitigate: stage NPC-first + gate before
Slice 2; preserve every escape (§5); keep the sweep/registry/managers; the `node_fail_counter`
prevents a stuck-remote freeze.
## 9. Register bookkeeping (do in the landing commits)
- Retire the "remotes skip the transition / server already collision-resolved" adaptation
(`GameWindow.cs:10089`/`:10275`, #40) — its "Risk if assumption breaks" IS the arms-overlap. Retire
in the Slice-2 commit that adds the sweep to Path A.
- Add rows for: the NPC `MoveOrTeleport` near-distance constant (96 m), the sticky-suppression
retention, and (if kept) the shadow-follows-server-truth vs collision-resolved decision.
## 10. Retail anchors (addresses, for `grep named-first` in the fresh session)
`CPhysics::UseTime` 0x00509950 · `update_object` 0x00515d10 · `UpdateObjectInternal` 0x005156b0 ·
`UpdatePositionInternal` 0x00512c30 · `transition` 0x00512dc0 · `find_obj_collisions` 0x0052b750 ·
`MoveOrTeleport` 0x00516330 · `HandleReceivedPosition` 0x00453fd0 · `InterpolateTo` 0x005104f0 /
`InterpolationManager::InterpolateTo` 0x00555b20 · `adjust_offset` 0x00555d30 · `UseTime` 0x00555f20 ·
`set_local_velocity` 0x005114d0 · `apply_interpreted_movement` 0x00528600.
## 11. Pointers
- Design spec: `docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`.
- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md`.
- The #182 player rebuild (shipped this session, orthogonal base): plan
`docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md`; the histogram
`tools/analyze_resolve_capture.py`.
- Research: workflow `wf_d2ff782f-9cb` (4-agent read-only sweep; findings distilled into §1-§3 here).

View file

@ -0,0 +1,158 @@
# Handoff: #185 — LOCAL player jams half-way up outdoor stairs (house on stilts)
**Date:** 2026-07-08 · **Status:** ROOT CAUSE PINNED (definitive, probe-confirmed) — fix NOT
started. The fix touches the **frozen collision internals** (the #137 / TS-4 sliding-normal
family), so per the project workflow it needs a **brainstorming gate BEFORE any code**. This
handoff is the investigation; do the brainstorm + fix in the new session.
**Read these first, in order:**
1. This handoff.
2. `claude-memory/project_physics_collision_digest.md` — the #137 banners + the DO-NOT-RETRY
table + the sliding-normal-provenance lessons (this is the SSOT for the collision arc).
3. `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md` — the #137 sliding-normal
lifecycle audit (persist / absorb / clear; the synthetic-normal mechanism).
4. `docs/architecture/retail-divergence-register.md` — rows **TS-4** (steep-tangent
`SetSlidingNormal`), **TS-45** (retired — `SphereCollision` leak), **AP-83/84** (CSphere/
CylSphere family).
5. `docs/ISSUES.md` #185 (the one-paragraph version).
---
## 0. Symptom
Running the **LOCAL player** up the outside stairs of a house-on-stilts, you hit an invisible
block **half-way up** — you shuffle sideways in place but cannot advance up. **A jump clears
it** and you continue up the rest of the stairs normally. Pre-existing (seen before the #184
work); unrelated to the remote de-overlap thread.
---
## 1. Root cause — DEFINITIVE (probe-confirmed): a SYNTHETIC sliding normal, not geometry
The stairs are **real, walkable 38.7° tread quads** in the building collision mesh. The player
walks straight up them. At **one convex tread edge** (near a building-part seam), the foot
sphere straddles the edge, hits the lower tread poly, and the collision **RESPONSE fabricates a
perpendicular normal** — the tread normal **rotated 90°** — which is then flattened to a pure
horizontal `(0,1,0)` sliding plane. The player's +Y (up-stairs) movement is fully absorbed by
that plane → **sideways-only slide** (pinned in Y and Z). The jump clears it because the jump's
+Z velocity is not in the horizontal slide plane.
**It is NOT geometry, NOT a step-height / step-up-budget issue, NOT a missing riser.**
### The evidence
**A — `ACDREAM_CAPTURE_RESOLVE` (structured player-resolve JSONL).** At the jam the player is
pinned at **Y=77.812, Z=61.404** and only slides in X (~0.10 m of the ~0.68 m requested).
Constant across every stuck frame:
- `collisionNormal = (0.00, 0.78, 0.62)` — a ~52° face (**NOT** the #137 negated-run-direction).
- `slidingNormal = (0, 1, 0)` — that collision normal with **Z zeroed + renormalised** (horizontalised).
- `contactPlane / walkablePlane = (0, -0.625, 0.781)` — the 38.7° tread the player stands on.
- `result.collisionNormalValid=false` on the zero-move frames (the offset is pre-absorbed → target==current).
**B — `ACDREAM_PROBE_BUILDING=1` (`[resolve-bldg]` hit-poly log).** The decisive read: at the
jam, **ALL 156 building collisions are ONE polygon** — the 38.7° tread:
```
[resolve-bldg] obj=0xF6822103 entityId=0x00F68221 partIdx=3
gfxObj=0x01000AC5 hasPhys=True bspR=1.05 vAabbR=1.15
entOrigin_lb=(132.0,77.2,60.4)
hitPoly: numVerts=4 plane=(-0.625,0.000,0.781,-0.312)
v0_world=(132.75, 77.50, 61.01) v1_world=(131.25, 77.50, 61.01)
v2_world=(131.25, 77.00, 60.62) v3_world=(132.75, 77.00, 60.62)
```
- **No riser / wall polygon exists in the collision mesh** — the only distinct hit-poly plane
across all 156 hits is that tread.
- Local plane `(-0.625,0,0.781)` maps (building rotation ≈ +90° about Z) to the **world tread
normal `(0,-0.625,0.781)`** — matching capture A's contact plane.
- The capture's blocking normal `(0,0.78,0.62)` is that **tread normal rotated 90°**
(perpendicular: dot ≈ 0). It does **not** exist in the dat → **synthetic**, fabricated by the
collision response.
### Geometry facts (for the repro / test fixture)
- Object `0xF6822103` (entity), `entityId=0x00F68221`, `partIdx=3`; **gfxObj `0x01000AC5`**
(+ a neighbouring building part `gfxObj 0x01000ACA`, `obj 0xF6821201`, right at the seam).
- Landblock `0xf682`, cell `0xF682002C`. **Login spawn `@0xF682002C` is at the house** (the
streaming recenters to (246,130) on spawn), so the stairs load immediately — no travel needed.
- Jam world position ≈ **(132, 77.8, 61.4)**; the involved tread quad world verts are listed
above (X 131.25132.75, Y 77.0077.50, Z 60.6261.01; ~0.4 m rise / 0.5 m run = 38.7°).
- The player collides as a **body SPHERE** (human Setup 0x02000001; r≈0.48), so the relevant
response is the **CSphere family** (dispatcher `0x00537A80`, `slide_sphere 0x00537440`) — the
one the #182 port (`96ae2740`) rebuilt.
---
## 2. Classification + where the fix lives
**#137 / TS-4 "synthetic sliding-normal at a convex edge/seam" family — stair edition.** Same
class as the corridor-phantom work (`a11df5b8`, `e8651b38`) and the seam-shake fix
(`d4869154`). The building geometry is fine; our slide/validate response invents the wall.
**The core open question for the brainstorm:** *where does the 90°-rotated normal get
fabricated?* Candidates (trace these):
- `CSphere::slide_sphere` (`0x00537440`) — the crease `collisionNormal × contactPlane.Normal`
at a convex edge (a cross product IS a 90° rotation; the tread × something could yield the
perpendicular normal). **Prime suspect.**
- `validate_transition` (`0x0050aa70`) sliding-normal epilogue (the only in-transition
sliding-normal writer per the #137 audit) — is it persisting a fabricated normal?
- The step-up / neg-poly path at a convex edge (the seam-shake class — a lifted CheckPos lost,
re-test grazes the neighbour tread).
- The CSphere `collide_with_point` / edge-vertex case at the tread top edge.
The `(0,1,0)` horizontalisation of `(0,0.78,0.62)` (Z zeroed) is the **absorb** step — the
player's forward offset is projected out. Find who WRITES `(0,0.78,0.62)` first.
---
## 3. Apparatus (reusable — RE-CAPTURE in the new session; the temp files won't persist)
Repro: launch (canonical live command, CLAUDE.md), spawn `@0xF682002C`, run up the outside
stairs, hold forward against the jam (~world 132,77.8,61.4) for a couple of seconds.
- `ACDREAM_CAPTURE_RESOLVE=<path>` → per-player-resolve JSONL (inputs + PhysicsBody before/after
+ ResolveResult, filtered to IsPlayer). Analyse: find blocked-while-moving frames
(`|targetcur|_xy > 0.03` but `|resultcur|_xy < 0.4×that`); read `collisionNormal`,
`slidingNormal`, `contactPlane`, `walkableVertices`.
- `ACDREAM_PROBE_BUILDING=1``[resolve-bldg]` blocks (gfxObj, hit-poly plane + world verts).
**⚠️ GOTCHA:** the diagnostic Console output lands in the **raw background-task `.output`
file** (the process stdout captured by `run_in_background`), **NOT** the `Tee-Object` log —
`Tee` buffers and shows almost nothing. Grep the task `.output`. Also enables `[bsp-test]` /
`[bldg-channel]` (very high volume) — grep for `resolve-bldg` / `hitPoly`.
- Optional: `ACDREAM_PROBE_RESOLVE=1` / `ACDREAM_PROBE_PUSH_BACK=1` for the step-up / push-back
dispatch internals if the fabrication site needs a finer trace.
- Build a **dat-backed replay test** reproducing the tread-edge hit to the millimetre (pattern:
`Issue137CorridorSeamReplayTests`) — the fix's red→green pin. Dump the cell/GfxObj with
`ACDREAM_DUMP_GFXOBJS=0x01000AC5` (+ `_DIR`) for the fixture.
---
## 4. DO-NOT-RETRY
- **Do NOT touch the step-up budget or step-up logic.** There is no riser here — it's a
continuous walkable 38.7° ramp of tread quads; the jam is a fabricated normal, not a tall
step. (The register/digest already warned: stair sticks are the sliding-normal family; six
past step-up attempts were all wrong.)
- **Do NOT "fix the geometry" / mesh generation.** All 156 building hits are ONE real, walkable
tread poly; the dat is fine. There is no bad 52° face to remove.
- **Do NOT read the Tee'd launch log for `[resolve-bldg]`** — it's buffered/empty; read the raw
task `.output` (§3 gotcha).
- **Do NOT ship a per-frame sliding-normal clear or a small-offset-abort patch** (the #137
DO-NOT-RETRY): fix the normal's PROVENANCE, not the symptom. A leaked/fabricated normal +
success writeback = an absorbing wedge at empty space — the exact pattern the corridor fix
addressed by routing through the faithful `SlideSphere` and returning COLLIDED on the
opposing branch.
---
## 5. Acceptance
- The LOCAL player **walks up the full outdoor staircase without jamming or jumping**; no
sideways-slide pin at the ~Y 77.5 / Z 61.0 tread edge.
- **Regression pass** (the whole point — this is the shared slide response): indoor cellar/
corridor stairs (#137 repros), other outdoor buildings, ramps, and flat ground — **no new
phantom blocks AND no players sliding through walkable ramps/edges.** Un-skip / keep green the
`Issue137*` replay suites.
- Register bookkeeping in the fix commit (amend TS-4 / add a row as the change dictates).
## 6. Pointers
- ISSUES `#185`; committed root cause `99c22fad` (+ filing `29e01c38`).
- Collision digest DO-NOT-RETRY table + the #137 corridor/seam banners.
- Register TS-4 / TS-45 / AP-83 / AP-84; #137 fixes `a11df5b8`, `e8651b38`, `d4869154`;
CSphere port `96ae2740`.

View file

@ -0,0 +1,162 @@
# Handoff: #186 — indoor GREY flap at a top-floor connecting room (new house type)
> **✅ RESOLVED 2026-07-08 · fix `8257b9ba` · live gate PASSED.** The retail cdb trace (§3, run via
> `tools/cdb/issue186-connector-decider.cdb`) OVERTURNED both hypotheses below. Retail roots at the
> connector `0118` at the grey pose (same as acdream — so NOT the PICK fork) AND still DRAWS the player
> room `0116` from that root — because retail's `PView::InitCell` side test reads the dat **`PortalSide`
> bit**, while acdream's render path reconstructed the portal's interior side from the cell **AABB
> centroid**, which mis-sides a THIN connector cell (`0118`, 5 render polys → centroid on the wrong side
> of the `0118→0116` doorway → back-portal cull → grey). Fix = derive `InsideSide` from the dat bit in
> `GameWindow.BuildLoadedCell`, matching retail + acdream's own physics (`CellTransit.cs:190`). Surgical
> (dat diagnostic: agrees with the old centroid on every portal but the one #186 broke). **The §2/§3
> "acdream drops the room" read was right; the "acdream over-switches the root / flood-epsilon" mechanism
> was WRONG — it was the InsideSide *source*, not the pick or the epsilon.** Everything below is the
> pre-fix investigation, kept for the trail.
**Date:** 2026-07-08 · **Status:** ~~ROOT CAUSE NARROWED (report-only) — fix NOT started.~~ RESOLVED (see banner). It lives in
the FROZEN portal-flood / viewer-cell render code (the doorway-FLAP family that cost weeks), so it needs
a **retail cdb trace** to pin the last fork BEFORE any code, then a careful change. Milestone: M1.5.
**Read these first, in order:**
1. This handoff.
2. `claude-memory/project_render_pipeline_digest.md` — the render/doorway-FLAP SSOT + DO-NOT-RETRY table
("root at VIEWER cell"; the deleted EyeInsidePortalOpening rescue; PortalSideEpsilon root-lag).
3. `docs/architecture/retail-divergence-register.md` rows **AD-17** (≤8 clip-plane / union-AABB scissor),
**AD-20** (camera-sweep fallback seeds eye cell from PLAYER cell), **AD-21** (null-clipRoot outdoor fallback).
4. `docs/ISSUES.md` #186.
---
## 0. Symptom (user, live)
Top floor of a **house type not tried before**; a connecting opening/room between two parts of the house.
Passing through → a brief **GREY** flap (the framebuffer clear color). Stopping at the precise spot → the
WHOLE screen is grey and stays; **turning the camera clears it, turning back → grey again** (camera-DIRECTION
dependent, player stationary). A jump is NOT involved (that's #185). **Retail is SEAMLESS at the same spot
(user-confirmed) → this is a real acdream bug, not inherent.**
## 1. The cells + the grey pose (ACDREAM_PROBE_FLAP capture)
Landblock `0xF682`. Three cells, all envId `0x0365`, cell-struct origin `(132,84,62)`:
| cell | render polys | physics polys | portals | role |
|---|---|---|---|---|
| `0xF6820116` | **15** | 12 | →`0117,0118,0113` | player's room (closed) |
| `0xF6820117` | **10** | 6 | →`0118,0116,0115,0119` | next room (closed) |
| `0xF6820118` | **5** | 3 | →`0117,0116` | **thin connector** (root when grey) |
Grey-pose facts (from `[flap]`/`[flap-cam]`/`[flap-sweep]`, 88,249 frames; the grey frames are `vis=1`
`root=0xF6820118`, ~6,725 frames):
- `[flap-cam]`: `root=viewerCell=0xF6820118 playerCell=0xF6820116 eyeInRoot=Y playerInRoot=n
terrain=Skip outVisible=False eye=(134.81,83.59,69.07) player=(137.30,83.32,66.82)`.
- `[flap]` grey line: `root=0118 | p0->0x0117 D=0.14 TRV proj=4 clip=0 | p1->0x0116 D=0.18 CULL proj=5
clip=4 || outPolys=0 vis=1`. (Dominant D to 0117 ≈ 0.14; ALWAYS positive 0.000.50 → eye is genuinely
INSIDE 0118, never across the plane.)
- `[flap-sweep]`: `ok=True resolved=Y pulledIn=-0.00` (the chase boom reaches FULL distance, NO collision
pull-in, seating the eye in the connector).
**Read of the geometry:** the eye sits in the sparse connector `0118`, in the doorway threshold, looking
BACK toward the player's room `0116`. `p1->0116` is ON-screen (proj=5 clip=4) but side-CULLED (eye ~0.180.31
on `0116`'s side of the p1 plane — a back-portal). `p0->0117` is genuinely OFF-screen (`clip=0` with proj≥3
= off-screen per the builder's own probe doc, `PortalVisibilityBuilder.cs:818-821`). So no neighbour floods,
only the 5-poly connector draws, and its shell doesn't cover the forward view → the framebuffer clear color
(fog, `GameWindow.cs:8968`) shows through = GREY. Turning re-aims off the back-portal → a room floods → picture.
## 2. What is CONFIRMED / RULED OUT (workflow `wf_362bbeda-933`, 4 tracers + synthesis, + my own re-checks)
- ❌ **NOT null viewer-root / AD-21** — the render root is a valid indoor cell on 88,248/88,249 frames
(one lone `[outdoor-node] root=OUT`). (This refutes THE FIRST hypothesis in this investigation.)
- ❌ **NOT viewer-cell root-lag / AD-20**`eyeInRoot=Y` at every grey frame: the eye is geometrically
INSIDE `0118`'s BSP. The root is the fresh eye-derived swept `curr_cell` (`PhysicsCameraCollisionProbe.cs:92`),
NOT a stale player-seeded fallback. (Refutes the workflow synthesis's own leading "31 cm gap" theory —
the eye is NOT across the boundary; I checked the `eyeInRoot` flag directly.)
- ❌ **NOT the flood-admission being unfaithful** — the empty-clip cull (`PortalVisibilityBuilder.cs:320-324`)
and the back-portal side-CULL (`:292-298`, `CameraOnInteriorSide :857-863`) are faithful ports:
retail `PView::ClipPortals` gates neighbour processing on `if (ecx_8 != 0)` (pc 005a5660) and
`AddViewToPortals` on `view_count != 0` (pc 005a5357). #177 already proved acdream's flood is retail-identical.
- ❌ **NOT the color clear (the tempting "3-line fix" — DEAD END, do not revisit).** Retail's DrawCells
(`0x005a4840`, decomp line 432709) has its only `Clear` INSIDE `if (outside_view.view_count > 0)`
(line 432715), right AFTER `LScape::draw` (the landscape draw, 432719). The Clear is
`Clear(4, &RGBAColor_Black, 1.0f)` (`0x820fc0 = RGBAColor_Black`) — but clearing COLOR *after* drawing the
landscape would erase it, so that `4` flag is **depth/stencil, not color**. So retail's color clear is NOT
gated here, and matching "don't color-clear a sealed interior" would be WRONG. (This is why I did the cdb
prep — it killed a wrong fix before shipping.)
- ✅ **It IS a real acdream bug** (retail seamless) in the **doorway-FLAP flood/pick family**: acdream drops
the room the camera looks back at (`0116`) from the connector root; **retail keeps it drawn.**
## 3. THE ONE OPEN FORK → the retail cdb trace (do this FIRST in the new session)
Retail is seamless, so retail either (A) **roots at the player's room `0116`, not the connector** (a
viewer-cell PICK divergence — acdream over-switches to `0118`), or (B) **roots at `0118` but its flood still
admits `0116`** (a FLOOD divergence — retail keeps the back-portal / uses an on-screen sliver acdream drops).
Static decomp cannot decide; observe retail.
**Toolchain (CLAUDE.md "Retail debugger toolchain"; binary CONFIRMED pairs with `refs/acclient.pdb` via
`py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe"` → MATCH).**
- User: launch retail, connect to local ACE, walk to the SAME connecting room, and **HOLD the grey pose**
(so most captured frames are it).
- `bp acclient!SmartBox::update_viewer` (`0x00453ce0`) → dump `viewer_cell` (a `CObjCell*`, `acclient.h:35194`)
and its cell id. AND/OR `bp acclient!PView::DrawCells` (`0x005a4840`) → dump `cell_draw_num` +
`cell_draw_list.data[]` ids (`acclient.h:45939-45940`) — the drawn cell set.
- Decide: retail `viewer_cell == 0116`**PICK fix** (acdream's viewer-cell pick/hysteresis over-retains
the connector — `CellTransit.cs:862-916 BuildCellSetAndPickContaining`, or the camera boom coasting the eye
into the connector at `pulledIn=-0.00` where retail's PathClipped `viewer_sphere` sweep hard-stops in
`0116`). retail `viewer_cell == 0118` but `cell_draw_list``0116`**FLOOD fix** (retail admits the
back-portal from the connector; acdream's side-CULL / the deleted EyeInsidePortalOpening rescue is the site).
- cdb watchouts (CLAUDE.md): `qd` ONLY at a top-level threshold, never inside a conditional bp action (strands
cdb → kills retail); `dt acclient!Class @ecx field` for reads (`@@c++` is broken here); `.sympath <local>`
(no `srv*`, the `;` splits); spaced `.if ()` lets `qd` detach.
## 4. Apparatus (re-create in the new session — temp files won't persist)
- **Flap capture:** launch acdream with `ACDREAM_PROBE_FLAP=1` (canonical live cmd, CLAUDE.md). Emits per
frame: `[flap]` (root + per-portal proj/clip/side, `PortalVisibilityBuilder.cs:790`), `[flap-cam]`
(`root/viewerCell/playerCell/eyeInRoot/playerInRoot/terrain/outVisible`, `GameWindow.cs:11142-11152`),
`[flap-sweep]` (`ok/resolved/pulledIn/viewerCell`, `PhysicsCameraCollisionProbe.cs:74-87`), `[outdoor-node]`
(`root=IN/OUT`). ⚠️ heavy (per-frame); output goes to the raw background-task `.output`, not the Tee'd log.
Have the user HOLD the grey pose + turn to clear + turn back a couple times.
- **Offline cell geometry dump:** `tests/AcDream.Core.Tests/Physics/Issue186ConnectorCellGeometryInspectionTests.cs`
(`Dump_ConnectorCells_ShellAndCollision`) loads `0116/0117/0118` from the dat and prints render/physics poly
counts + normal-axis buckets. No live launch needed. (That's how the §1 table was produced.)
- **Decomp workflow:** `wf_362bbeda-933` (this session) — the 4-tracer + synthesis analysis; results in its
journal under the session's `subagents/workflows/` dir.
## 5. DO-NOT-RETRY (each already refuted or a known trap)
- Do NOT re-open the **null-root / AD-20 / AD-21** angle — root is valid, `eyeInRoot=Y`.
- Do NOT implement the **color-clear gating** — retail's gated `Clear` is depth/stencil (post-`LScape::draw`);
matching it would add non-retail stale-content.
- Do NOT re-add the deleted **`EyeInsidePortalOpening` rescue** (`PortalVisibilityBuilder.cs:314-319`) blindly
— it caused the `0171↔0173` flood-cycle churn (the ORIGINAL doorway flap).
- Do NOT widen **`PortalSideEpsilon`** (`:38-56`) — a documented root-lag band, NOT retail (F_EPSILON=0.0002);
BR-4 refuted tightening/widening without eye-exact tracking.
- Do NOT "fix" the flood by propagating an empty/edge-on portal — the empty-clip cull is retail-faithful.
- The sparse connector geometry (`0118` = 5 render / 3 physics polys) is FAITHFUL to the dat (retail draws the
same). It is context, not the bug — the bug is that `0116` isn't drawn, not that `0118` is thin.
## 6. Key code sites
- Frame-top clear (unconditional color+depth+stencil to fog): `GameWindow.cs:8968-8983`. **NOT the fix.**
- Viewer-cell resolution: `GameWindow.cs:9137-9148` (`viewerCellId = _retailChaseCamera.ViewerCellId`).
- Camera boom sweep: `PhysicsCameraCollisionProbe.cs` (`SweepEye`, PathClipped, `pulledIn`, `r.CellId=curr_cell`).
- Viewer-cell PICK: `CellTransit.cs:862-916` (`BuildCellSetAndPickContaining`, center-based `point_in_cell`,
current-cell-first hysteresis, port of `find_cell_list` pseudo_c:308788-308825).
- Portal FLOOD: `PortalVisibilityBuilder.cs` (`Build`; side-test `CameraOnInteriorSide :857-863`; empty-clip
cull `:320-324`; `PortalSideEpsilon :38-56`; the off-screen-vs-sliver note `:818-821`).
## 7. Retail decomp anchors
- `PView::DrawCells` `0x005a4840` (decomp line 432709): Clear gated on `outside_view.view_count>0` (432715),
Clear at 432732 (depth/stencil, post-`LScape::draw`); cells drawn unconditionally after (432815+).
- `SmartBox::update_viewer` `0x00453ce0`: `viewer_cell = sphere_path.curr_cell` (the render root = swept cell).
- `PView::ClipPortals` empty-clip gate `if(ecx_8!=0)` pc 005a5660; `AddViewToPortals` `view_count!=0` pc 005a5357.
- `PView::InitCell` side test decomp:432962. `RGBAColor_Black` @ `0x820fc0`.
## 8. Acceptance
- No grey flap passing through OR stopping in the connecting room at any camera angle; matches retail's seamless.
- Regression: the outside→inside doorway + other multi-room houses + the #177/#181 render-flap suites unchanged.
## 9. Context: this session also FIXED #185 (unrelated, DONE + gated)
The outdoor-stairs "invisible wall" (`07c5b832`) — a shadow part-id uint32 overflow dropping stair collision.
Unrelated to #186 (that was collision/registration; #186 is render/visibility). See
`docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md`.

View file

@ -0,0 +1,141 @@
# A7 dungeon lighting — Town Network "too dark" ROOT-CAUSED to the light cap (ambient RULED OUT). Pickup handoff.
**Date:** 2026-07-09
**Status:** investigation COMPLETE (report-only). Fix NOT started — deferred to a new
implementation session (this doc + the separate pickup prompt).
**Milestone:** M1.5 — Indoor world feels right. Critical path item **A7 dungeon
lighting (#79/#93/#176/#177)**.
**Read FIRST in the new session:** `claude-memory/project_render_pipeline_digest.md`,
`claude-memory/reference_retail_ambient_values.md`, then the two 2026-07-06 A7 docs
(`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` +
`docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md` — the source-confirmed
retail per-cell light model, **including its two CORRECTION banners**), then this file.
---
## The user's report
> "We are still missing some lighting indoors. It's darker than retail… Not the
> torches, the ambient lighting. I'm checking in the Town Network — we have a
> fountain in the main room but it's much darker and not lit up as in retail."
The user's *observation is correct* (the room IS too dark). The *attribution*
("ambient") is what this investigation tested — and disproved.
## VERDICT: the ambient is retail-faithful. The darkness is the LIGHT CAP.
Confirmed **three independent ways** that acdream's indoor ambient is correct:
1. **Decomp.** `CellManager::ChangePosition` (0x004559B0) sets the world ambient on a
two-way branch on the player cell's `seen_outside` flag: sealed
(`seen_outside==0`) → `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` (flat 0.2
white); seen-outside/outdoor → `SetWorldAmbientLight(calc_object_light(),
LScape::ambient_color)` (bright sky ambient). acdream mirrors this exactly in
`GameWindow.UpdateSunFromSky` (flat 0.2 white when `playerInsideCell`).
2. **This cell's dat flag.** The `[light]` probe in the Town Network fountain room:
`insideCell=True ambient=(0.2,0.2,0.2) sun=0 playerCell=0x00070144`. acdream reads
`SeenOutside` faithfully off the player's physics cell (`CellGraph.CurrCell`), so
`insideCell=True` ⇒ the dat flag for 0x00070144 is genuinely "sealed."
3. **Live retail cdb capture (2026-07-09).** Attached cdb to retail acclient.exe
(v11.4186, MATCHES `refs/acclient.pdb`), bp on `SmartBox::SetWorldAmbientLight`
(`?SetWorldAmbientLight@SmartBox@@QAEXMK@Z`; args thiscall: level=float `dwo(@esp+4)`,
color32 `dwo(@esp+8)`). Portalling from outdoors INTO the Town Network:
| phase | levelraw | level | color32 | |
|---|---|---|---|---|
| outdoor | `0x3ee210c9`/`0x3ee2237d` | ≈0.44 | `0xffc864ff` = (200,100,255) purple | sky ambient |
| **Town Network interior** | **`0x3e4ccccd`** | **0.20** | `0xffffffff` white | **matches acdream exactly** |
`0x3e4ccccd` == `0.2f` bit-exact. **Retail runs the Town Network on the same 0.2
flat white ambient acdream does.** Raising the ambient would make acdream *wrong*.
## The REAL cause (the deferred A7 issue, now with a bigger repro)
The `[light]` probe headline: **`registeredLights=463`, cap `MaxGlobalLights=128`,
`activeLights=8`.** The Town Network hub registers **463** fixtures;
`LightManager.BuildPointLightSnapshot(playerWorldPos)` keeps only the **128 nearest the
player** over the WHOLE resident set, evicting ~335/frame. `SelectForObject` (the
faithful per-object ≤8 pick) can only choose from the surviving 128, so the hub's fill
lighting is starved and the room falls back to mostly the (correct) 0.2 ambient →
reads dark. Retail keeps the same 0.2 ambient but scopes its light pool to the
portal-flood **visible cells** (tiny 40+7 cap never bites), so every visible cell keeps
its own fixtures → the room reads bright. This is exactly **#79/#93/#176/#177**, now
reproduced at 463 fixtures (bigger than the Facility Hub's 366 where it was found).
## Current code state (verify — this doc dates fast)
- `src/AcDream.Core/Lighting/LightManager.cs`: `MaxGlobalLights = 128`;
`BuildPointLightSnapshot(Vector3 playerWorldPos)` caps the whole `_all` resident set
at 128 **nearest-player** (camera-invariant — the camera-coupling bug is already
fixed). There is **no** visible-cell scoping right now.
- The visible-cell scoping WAS shipped once (`c500912b`) and **reverted** — the first
attempt scoped by the *camera*-flood and churned the pool as the camera turned (the
#176 seam-floor blink). See the pseudocode doc's CORRECTION #2.
- Renderers already select per-cell (`EnvCellRenderer.cs:~1088`) and per-object
(`WbDrawDispatcher.cs:~2095`) from `LightManager.PointSnapshot`. The only defect is
how `PointSnapshot` is *built*.
## The fix shape (A7.L1 — port the visibility-scoped per-frame collection)
Retail's model (source-confirmed, pseudocode doc §1): each cell owns a light list;
per frame ONLY the visible cells push their lights into a small global player-nearest
pool (40 static + 7 dynamic). The candidate pool is pre-scoped by *visibility*, so the
tiny cap never bites. Port THAT, not a bigger cap:
1. **Build the `[indoor-light]` per-cell composition probe FIRST** (the A7 handoff's
A7.L1 apparatus — prints per-cell active-light SET MEMBERSHIP: cellId → {pos, color,
attenuation, dir, isDynamic}, plus registered/pool/dropped counts). The current
`[light]` probe prints COUNTS, not membership — it can't show which cell lost which
light. Build the membership probe before touching the pool.
2. **Tag each `LightSource` with its owning cell id** (`CellId`, populated at every
registration site from `entity.ParentCellId`; viewer light stays `CellId==0`).
Retail's `add_*_light(info, cellId, frame)` carries exactly this.
3. **Feed the per-frame pool from the currently-visible cells** (the portal-flood set
the renderer already computes), not the whole `_all` set — CORRECTLY this time:
scope by the **player/visible-cell flood**, NOT the camera position (that was the
reverted attempt's bug). The pool is then naturally bounded and the 128 cap stops
biting. Un-skip
`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`.
4. **(Fix #2, follow-on) static curve for stationary fixtures** — a server-spawned
wall lantern is stationary → static 1/d³ (range×1.3), reserve `isDynamic`
(1/d, range×1.5) for genuinely MOVING lights. This is over-BRIGHTENING, not the
darkness — separate slice.
5. **(Fix #3) hunt the striped floor artifact** with the full pool on (see the
2026-07-06 handoff §3).
## DO-NOT-RETRY / gotchas
- **Do NOT raise the ambient.** 0.2 white is retail-verified three ways (this doc).
- **Do NOT re-scope the pool by CAMERA position** (the reverted `c500912b` bug →
#176 seam blink). Scope by the visible-cell flood / player.
- **Do NOT just raise `MaxGlobalLights`** (128→1024 was tried `4d25e04d`, reverted —
it exposed the through-floor purple wash + stripe artifacts the cap was masking).
- **cdb note for next time:** the `Position→objcell_id` offset used here
(`dwo(dwo(@esp+4))` at `ChangePosition` entry) was WRONG — the cell id printed stuck
at a constant. The `level`/`color32` capture on `SetWorldAmbientLight` was correct.
Find the real `Position` layout (or read the `Render::player_pos` global set at
0x00455ab6) before trusting a cdb-printed cell id.
## Apparatus / evidence produced this session
- Live probe (already in the tree): `ACDREAM_PROBE_LIGHT=1``[light]` counts;
`ACDREAM_PROBE_CELL=1``[cell-transit]`. Fountain-room reading:
`insideCell=True ambient=(0.2,0.2,0.2) registeredLights=463 activeLights=8
playerCell=0x00070144`.
- Retail cdb scripts (throwaway, in the session scratchpad): captured retail interior
ambient = 0.2 white. The `SetWorldAmbientLight` bp recipe above is reusable.
## References
- `docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md` — the retail model
(source-confirmed) + BOTH correction banners. **The spec for the port.**
- `docs/research/2026-07-06-176-177-handoff-A7-lighting.md` — the #176/#177 root cause,
the reverted cap-raise, the tooling built.
- `claude-memory/reference_retail_ambient_values.md` — ambient SSOT (now with the
2026-07-09 live-verified 0.2 interior confirmation).
- Retail anchors: `CellManager::ChangePosition` 0x004559B0;
`SmartBox::SetWorldAmbientLight` 0x004530a0; `CObjCell::add_light` 0x0052b1d0;
`CEnvCell::add_dynamic_lights` 0x0052d410; `Render::insert_light` 0x0054d1b0
(cap 40+7); `Render::minimize_object_lighting` 0x0054d480; `calc_point_light`
0x0059c8b0 (static 1/d³ curve).

View file

@ -0,0 +1,41 @@
# MP-Alloc Safe Batch — kill the per-frame garbage that spikes frame time
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox syntax.
**Goal:** Eliminate the largest *easy, bit-identical* per-frame allocations in the render/update hot path, so the GC-pause spikes that make FPS/ms fluctuate wildly go away. Target: the profiler's `alloc_kb/frame` drops sharply and cpu_ms p99/max tighten toward the p50, with **zero visual or gameplay change**.
**Why this batch:** MP0 measured 1.53 MB/frame allocated in steady-state dense-town, driving gen2 GC ~12/s → 2087 ms frame spikes (the wild fluctuation the user sees on the counter). A 54-site allocation audit ranked the sources. This batch takes ONLY the sites that are (a) high-ranked, (b) pure buffer/list reuse — identical values, just not re-allocated — and (c) NOT faithfulness-sensitive. The two risky sites (EnvCellRenderer.PrepareRenderBatches settled-camera dirty-gate; PhysicsEngine `Transition` pooling) are DEFERRED to their own careful slices — do not touch them here.
**Binding rules:**
- **Bit-identical output.** Every change reuses a buffer/list that previously was `new`'d each frame. The produced values must be identical — reuse means `.Clear()` + refill or overwrite-in-place, never a semantic change. If a fix would change any drawn value, STOP and report.
- **Existing test suite stays green** (4120 tests). No test may be modified.
- **Thread-safety preserved.** Several of these run on the render thread only; some (particles) may be touched from multiple passes per frame — keep the existing access pattern; a per-owner reusable field is fine when the owner is single-threaded, otherwise report.
- **No new pooling framework.** Use plain reusable fields (`private readonly List<T> _scratch = new()`) cleared per use, or per-entity cached arrays. No ArrayPool ceremony unless a site obviously needs it.
- Measure with the existing `[frame-prof]` meter (env `ACDREAM_FRAME_PROF=1`) — before/after `alloc_kb` and cpu_ms p99/max.
---
## Sites (each its own commit; skip any that can't be made bit-identical and report)
### Task 1 — Animation pose buffer reuse (highest effort:payoff)
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs` (~747/770), `src/AcDream.App/Rendering/GameWindow.cs` (~10723).
Every animated entity (incl. idle NPCs on a breathe cycle) allocates a fresh `PartTransform[partCount]` in Advance AND a fresh `List<MeshRef>(partCount)` reassigned to `entity.MeshRefs` each frame (old → garbage). Fix: cache a `PartTransform[]` and a reusable `List<MeshRef>` per entity (sized to partCount), overwrite/clear in place, stop reassigning `entity.MeshRefs` to a new list. Verify: pose values identical (existing animation/conformance tests green); no consumer relies on `MeshRefs` being a fresh list each frame (check for cached references / identity comparisons before reusing).
### Task 2 — Particle draw-list + accumulator reuse
**Files:** `src/AcDream.App/Rendering/ParticleRenderer.cs` (~147/181), `src/AcDream.Core/Vfx/ParticleSystem.cs` (~161 `EnumerateLive`).
`Draw` is called many times/frame (sky pre/post, scene passes, per visible cell, dynamics, unattached), each `new`ing `List<ParticleDraw>` + `List<ParticleInstance>` + a yield-iterator. Fix: reuse `_drawList`/`_run` fields with `.Clear()`; replace the `EnumerateLive()` yield-iterator with a struct enumerator or index walk (no per-call iterator allocation). Keep the per-cell filtering semantics identical. (The deeper O(cells×particles) re-walk is a SEPARATE later structural fix — this task is only the buffer/iterator allocations.)
### Task 3 — Interior entity partition pooling
**File:** `src/AcDream.App/Rendering/InteriorEntityPartition.cs` (~46).
`Partition` news a `Result` (Dict + 2 Lists) + a `List<WorldEntity>` per visible cell every frame. Fix: make `Partition` reuse a cleared-in-place `Result` and pooled per-cell lists owned by the partitioner. Identical partitioning output.
### Task 4 — Trivial per-frame HashSet / small-collection reuse
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (~9092 `animatedIds`, note the near-duplicate at the DrawContext capture ~9314 — dedupe if they're the same intent), `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (~113 `drawableCells`), `src/AcDream.App/Rendering/IndoorDrawPlan.cs` (~20 ShellPass lists), and `DrainCompletions` (per the audit, ~one-liner).
Each rebuilds a fresh `HashSet<uint>`/`List<>` from keys every frame. Fix: reusable cleared-in-place fields. These are one-liners; batch them together in this one commit. If the two `animatedIds` sites are the same set built twice, build once and share.
---
## Verification (the gate)
- After each task: `dotnet build` + full `dotnet test` green.
- After the batch: `dotnet build -c Release`, then a live launch with `ACDREAM_FRAME_PROF=1`. The coordinator + user compare the `[frame-prof]` line in a dense town BEFORE (baseline: alloc_kb p50 ~16003000, cpu_ms max 2087) vs AFTER. Success = alloc_kb p50 down materially AND cpu_ms max/p99 tightened toward p50, with the user confirming the scene looks and plays identically.
- If a site's numbers don't move or a fix risks a visual change, report it — we escalate to the dotnet-trace profile to re-rank rather than guessing.

View file

@ -0,0 +1,754 @@
# MP0 — Honest Frame Profiler + Baseline Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the permanent frame profiler (CPU frame time, GPU frame time, per-stage CPU attribution, per-frame allocation) and capture the MP baseline report that gates the rest of the Modern Pipeline track.
**Architecture:** A pure, unit-testable stats core (`FrameStatsBuffer`) + a GL query wrapper (`GpuFrameTimer`) + a facade (`FrameProfiler`) wired into `GameWindow` at exactly one frame-boundary call plus three stage scopes. Toggle lives in `RenderingDiagnostics` (the established diagnostic-owner pattern), mirrored in the DebugPanel. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §5.
**Tech Stack:** .NET 10, Silk.NET OpenGL (`QueryTarget.TimeElapsed` ring, mirroring `WbDrawDispatcher`'s existing idiom), xunit in `tests/AcDream.App.Tests`.
**Spec deviations (recorded):**
1. Spec §5 lists per-stage GPU attribution via `glQueryCounter` (GL_TIMESTAMP) markers. Deferred: the measured GPU total is ~0.5 ms (the frame is CPU-bound), so MP0 ships per-stage **CPU** attribution + whole-frame GPU only. GPU timestamps get added in the MP phase whose gate first needs GPU-side attribution (likely MP3).
2. The spec says "Toggles via `RuntimeOptions` + DebugPanel". `RuntimeOptions`' own doc comment says runtime diagnostic toggles belong in diagnostic owner classes, NOT RuntimeOptions ([RuntimeOptions.cs:13-19](../../src/AcDream.App/RuntimeOptions.cs)). We follow the codebase rule: the flag goes in `RenderingDiagnostics` (env-seeded, runtime-toggleable). No RuntimeOptions change.
**Hard GL constraint (discovered during planning):** GL forbids two simultaneously active `TimeElapsed` queries. `WbDrawDispatcher` already brackets its opaque/transparent passes with `TimeElapsed` queries when `ACDREAM_WB_DIAG=1` ([WbDrawDispatcher.cs:1642](../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)). Therefore the profiler's whole-frame GPU query is **disabled (with a one-time console notice) when `ACDREAM_WB_DIAG=1`**. This is also the 2026-06-23 measurement lesson (separate flags for whole-frame vs per-pass) enforced in code.
**Threading assumption:** Silk.NET drives `OnUpdate` and `OnRender` on the same loop thread in this app. `FrameProfiler` captures the first caller's managed thread id and emits a one-time warning if any later call arrives on a different thread (guards the `GC.GetAllocatedBytesForCurrentThread` validity).
---
## File map
| File | Action | Responsibility |
|---|---|---|
| `src/AcDream.App/Diagnostics/FrameStatsBuffer.cs` | Create | Pure ring-buffer stats: samples, percentiles, stage accumulators, report line. Zero GL, zero statics. |
| `src/AcDream.App/Diagnostics/GpuFrameTimer.cs` | Create | GL `TimeElapsed` query ring (depth 4, `Begun` flags per the #125 lesson). |
| `src/AcDream.App/Diagnostics/FrameProfiler.cs` | Create | Facade: frame boundary, stage scopes, GC counters, 5-second `[frame-prof]` report. |
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | Modify | Add `FrameProfEnabled` flag (env `ACDREAM_FRAME_PROF`). |
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | One field + one call at `OnRender` top (~:8757), stage scope around `_wbMeshAdapter?.Tick()` (~:8813), around `_imguiBootstrap.Render()` (~:9821), and at `OnUpdate` top (~:7952). Wiring only — no logic in GameWindow. |
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | Modify | `FrameProf` mirror property (copy the `ProbeResolve` pattern at :248). |
| `src/AcDream.UI.ImGui/` (renderer for DebugPanel) | Modify | Checkbox for the mirror (locate via `grep -rn "ProbeResolve" src/AcDream.UI.ImGui/`). |
| `tests/AcDream.App.Tests/FrameStatsBufferTests.cs` | Create | Percentiles, ring wrap, stage reset, report format. |
| `docs/research/2026-07-XX-mp0-baseline.md` | Create (Task 5) | The baseline report — MP0's deliverable. |
Line numbers drift; match by the quoted anchor code, not the number.
---
### Task 1: FrameStatsBuffer (pure stats core, TDD)
**Files:**
- Create: `src/AcDream.App/Diagnostics/FrameStatsBuffer.cs`
- Test: `tests/AcDream.App.Tests/FrameStatsBufferTests.cs`
- [ ] **Step 1: Write the failing tests**
```csharp
using AcDream.App.Diagnostics;
using Xunit;
namespace AcDream.App.Tests;
public class FrameStatsBufferTests
{
[Fact]
public void Percentiles_OnKnownDistribution_AreExact()
{
var buf = new FrameStatsBuffer(capacity: 100);
// 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100.
for (long i = 1; i <= 100; i++) buf.Push(i);
Assert.Equal(50, buf.Percentile(0.50));
Assert.Equal(95, buf.Percentile(0.95));
Assert.Equal(99, buf.Percentile(0.99));
Assert.Equal(100, buf.Max());
}
[Fact]
public void Push_PastCapacity_KeepsOnlyNewestWindow()
{
var buf = new FrameStatsBuffer(capacity: 4);
foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 })
buf.Push(v);
// The four 1000s were overwritten; window is {1,2,3,4}.
Assert.Equal(4, buf.Count);
Assert.Equal(4, buf.Max());
Assert.Equal(2, buf.Percentile(0.50));
}
[Fact]
public void Percentile_Empty_ReturnsZero()
{
var buf = new FrameStatsBuffer(capacity: 8);
Assert.Equal(0, buf.Percentile(0.95));
Assert.Equal(0, buf.Max());
Assert.Equal(0, buf.Count);
}
[Fact]
public void Reset_ClearsWindow()
{
var buf = new FrameStatsBuffer(capacity: 8);
buf.Push(5); buf.Push(7);
buf.Reset();
Assert.Equal(0, buf.Count);
Assert.Equal(0, buf.Percentile(0.5));
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter FrameStatsBufferTests -v minimal`
Expected: FAIL — `FrameStatsBuffer` does not exist (compile error).
- [ ] **Step 3: Implement FrameStatsBuffer**
```csharp
using System;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
/// (microseconds or bytes) with percentile/max over the current window.
/// Pure and allocation-free after construction: <see cref="Percentile"/>
/// sorts into a preallocated scratch array, so the 5-second report path
/// allocates nothing. Not thread-safe — owned by the window loop thread.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameStatsBuffer
{
private readonly long[] _samples;
private readonly long[] _scratch;
private int _cursor;
private int _count;
public FrameStatsBuffer(int capacity)
{
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
_samples = new long[capacity];
_scratch = new long[capacity];
}
public int Count => _count;
public void Push(long value)
{
_samples[_cursor] = value;
_cursor = (_cursor + 1) % _samples.Length;
if (_count < _samples.Length) _count++;
}
public void Reset()
{
_cursor = 0;
_count = 0;
}
/// <summary>
/// Nearest-rank percentile over the current window: element at
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
/// </summary>
public long Percentile(double q)
{
if (_count == 0) return 0;
Array.Copy(_samples, _scratch, _count);
Array.Sort(_scratch, 0, _count);
int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
if (rank < 1) rank = 1;
if (rank > _count) rank = _count;
return _scratch[rank - 1];
}
public long Max()
{
long max = 0;
for (int i = 0; i < _count; i++)
if (_samples[i] > max) max = _samples[i];
return max;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests --filter FrameStatsBufferTests -v minimal`
Expected: 4 passed.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Diagnostics/FrameStatsBuffer.cs tests/AcDream.App.Tests/FrameStatsBufferTests.cs
git commit -m "feat(pipeline): MP0 - FrameStatsBuffer ring/percentile core"
```
---
### Task 2: RenderingDiagnostics flag + GpuFrameTimer
**Files:**
- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` (append inside the class, before the closing brace)
- Create: `src/AcDream.App/Diagnostics/GpuFrameTimer.cs`
- [ ] **Step 1: Add the flag to RenderingDiagnostics**
Append inside `public static class RenderingDiagnostics` (after `ShouldRenderIndoor`, before the final `}`):
```csharp
/// <summary>
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
/// do NOT strip with session probes. The whole-frame GPU query is
/// self-disabled while <c>ACDREAM_WB_DIAG=1</c> (GL forbids nested
/// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
/// that flag — the 2026-06-23 "separate flags" measurement lesson).
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
```
- [ ] **Step 2: Create GpuFrameTimer**
```csharp
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
private const int RingDepth = 4;
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
public GpuFrameTimer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
for (int i = 0; i < RingDepth; i++)
_queries[i] = _gl.GenQuery();
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// </summary>
public long? FrameBoundary()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
}
public void Dispose()
{
Stop();
for (int i = 0; i < RingDepth; i++)
_gl.DeleteQuery(_queries[i]);
}
}
```
Note: `_gl.GenQuery()` / `_gl.DeleteQuery(uint)` are the Silk.NET single-name helpers; if this overload doesn't resolve on the project's Silk.NET version, use the array forms exactly as `WbDrawDispatcher` does (`grep -n "GenQueries" src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`) and copy that call shape.
- [ ] **Step 3: Build**
Run: `dotnet build`
Expected: green (no tests possible for the GL class headless; correctness rides on the copied WbDrawDispatcher idiom + Task 5's live run).
- [ ] **Step 4: Commit**
```bash
git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs src/AcDream.App/Diagnostics/GpuFrameTimer.cs
git commit -m "feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring"
```
---
### Task 3: FrameProfiler facade
**Files:**
- Create: `src/AcDream.App/Diagnostics/FrameProfiler.cs`
- Test: `tests/AcDream.App.Tests/FrameProfilerReportTests.cs`
- [ ] **Step 1: Write the failing test (report formatting is the testable part)**
```csharp
using AcDream.App.Diagnostics;
using Xunit;
namespace AcDream.App.Tests;
public class FrameProfilerReportTests
{
[Fact]
public void FormatReport_IsInvariantAndComplete()
{
var cpu = new FrameStatsBuffer(16);
var gpu = new FrameStatsBuffer(16);
var alloc = new FrameStatsBuffer(16);
var stages = new[] { new FrameStatsBuffer(16), new FrameStatsBuffer(16), new FrameStatsBuffer(16) };
for (long i = 1; i <= 10; i++)
{
cpu.Push(i * 1000); // 1..10 ms in µs
gpu.Push(i * 100);
alloc.Push(i * 1024); // bytes
stages[0].Push(i * 200);
stages[1].Push(i * 50);
stages[2].Push(i * 10);
}
string line = FrameProfiler.FormatReport(
frameCount: 10, cpu: cpu, gpu: gpu, gpuActive: true,
alloc: alloc, gc0: 3, gc1: 1, gc2: 0, stages: stages);
Assert.StartsWith("[frame-prof]", line);
Assert.Contains("n=10", line);
Assert.Contains("cpu_ms p50=5.0 p95=10.0 p99=10.0 max=10.0", line);
Assert.Contains("gpu_ms p50=0.5", line);
Assert.Contains("alloc_kb p50=5.0 max=10.0", line);
Assert.Contains("gc=3/1/0", line);
Assert.Contains("upd p50=1.0", line); // stage 0: 200µs·5 = 1.0 ms
Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture)
}
[Fact]
public void FormatReport_GpuInactive_SaysWhy()
{
var empty = new FrameStatsBuffer(4);
string line = FrameProfiler.FormatReport(
frameCount: 0, cpu: empty, gpu: empty, gpuActive: false,
alloc: empty, gc0: 0, gc1: 0, gc2: 0,
stages: new[] { empty, empty, empty });
Assert.Contains("gpu=off(wbdiag)", line);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter FrameProfilerReportTests -v minimal`
Expected: FAIL — `FrameProfiler` does not exist.
- [ ] **Step 3: Implement FrameProfiler**
```csharp
using System;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AcDream.Core.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>Stage indices for per-frame CPU attribution.</summary>
public enum FrameStage
{
/// <summary>Whole OnUpdate body (simulation + streaming apply).</summary>
Update = 0,
/// <summary>WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.</summary>
Upload = 1,
/// <summary>ImGui Render (dev overlay).</summary>
ImGui = 2,
}
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
/// bool check per frame when off.
///
/// <para>Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under <c>ACDREAM_WB_DIAG=1</c>
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).</para>
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
private readonly FrameStatsBuffer[] _stageUs;
private readonly long[] _stageAccumTicks;
private readonly bool _wbDiagActive =
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
private long _lastAllocBytes;
private long _lastReportTicks;
private int _gc0Base, _gc1Base, _gc2Base;
private int _framesInWindow;
private int _ownerThreadId;
private bool _threadWarned;
private bool _wbDiagNoticePrinted;
private bool _wasEnabled;
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
_stageAccumTicks = new long[StageCount];
}
/// <summary>
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
/// </summary>
public void FrameBoundary(GL gl)
{
bool enabled = RenderingDiagnostics.FrameProfEnabled;
if (!enabled)
{
if (_wasEnabled) { _gpuTimer?.Stop(); _wasEnabled = false; _lastBoundaryTimestamp = 0; }
return;
}
if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId;
else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId)
{
_threadWarned = true;
Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable");
}
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
// baselines, emit nothing.
_wasEnabled = true;
_lastReportTicks = DateTime.UtcNow.Ticks;
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)");
}
}
else
{
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
_cpuUs.Push(cpuUs);
_allocBytes.Push(allocNow - _lastAllocBytes);
for (int i = 0; i < StageCount; i++)
{
_stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
if (_gpuTimer?.FrameBoundary() is long gpuUs)
_gpuUs.Push(gpuUs);
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
int gc0 = GC.CollectionCount(0) - _gc0Base;
int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base;
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0;
_cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset();
for (int i = 0; i < StageCount; i++) _stageUs[i].Reset();
}
}
/// <summary>
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
/// Zero-cost (default scope) when the profiler is off.
/// </summary>
public StageScope BeginStage(FrameStage stage)
=> RenderingDiagnostics.FrameProfEnabled
? new StageScope(this, stage, Stopwatch.GetTimestamp())
: default;
internal void EndStage(FrameStage stage, long startTimestamp)
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
/// <summary>Pure report formatter — unit-tested; invariant culture.</summary>
public static string FormatReport(
int frameCount,
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
FrameStatsBuffer alloc, int gc0, int gc1, int gc2,
FrameStatsBuffer[] stages)
{
var ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder(256);
sb.Append("[frame-prof] n=").Append(frameCount);
sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}",
cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0,
cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0);
if (gpuActive)
sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}",
gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0);
else
sb.Append(" | gpu=off(wbdiag)");
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}",
alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2);
string[] names = { "upd", "upl", "imgui" };
for (int i = 0; i < stages.Length && i < names.Length; i++)
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}",
names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);
return sb.ToString();
}
public void Dispose() => _gpuTimer?.Dispose();
}
/// <summary>Disposable stage scope; default instance is a no-op.</summary>
public readonly struct StageScope : IDisposable
{
private readonly FrameProfiler? _owner;
private readonly FrameStage _stage;
private readonly long _start;
internal StageScope(FrameProfiler owner, FrameStage stage, long start)
{
_owner = owner; _stage = stage; _start = start;
}
public void Dispose() => _owner?.EndStage(_stage, _start);
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests --filter FrameProfilerReportTests -v minimal`
Expected: 2 passed. (If the `,0` invariant assertion trips on stage names, adjust only the assertion's exclusion — the formatter must keep `CultureInfo.InvariantCulture` everywhere.)
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Diagnostics/FrameProfiler.cs tests/AcDream.App.Tests/FrameProfilerReportTests.cs
git commit -m "feat(pipeline): MP0 - FrameProfiler facade (CPU/GPU/alloc/stages, 5s report)"
```
---
### Task 4: GameWindow wiring + DebugPanel mirror
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
- Modify: the DebugPanel ImGui renderer (locate in step 4)
- [ ] **Step 1: Add the field and frame-boundary call**
Near GameWindow's other diagnostic fields (search for `_frameDiag` field declaration to co-locate):
```csharp
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
```
At the very top of `OnRender` (anchor: `private void OnRender(double deltaSeconds)` ~line 8756 — the call goes BEFORE the `var kf = WorldTime.CurrentSky;` line):
```csharp
_frameProfiler.FrameBoundary(_gl!);
```
In `Dispose`/`OnClosing` teardown (anchor: where `_wbMeshAdapter` is disposed — profiler goes before `_dats`):
```csharp
_frameProfiler.Dispose();
```
- [ ] **Step 2: Add the three stage scopes**
`OnUpdate` (anchor: `private void OnUpdate(double dt)` ~line 7952) — first statement of the body:
```csharp
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
```
Upload drain (anchor: `_wbMeshAdapter?.Tick();` ~line 8813 — wrap it, keeping the existing `[FRAME-DIAG]` lines untouched):
```csharp
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
}
```
ImGui render (anchor: `_imguiBootstrap.Render();` ~line 9821):
```csharp
using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
{
_imguiBootstrap.Render();
}
```
- [ ] **Step 3: Add the DebugVM mirror**
In `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`, after the `ProbeResolve` property (~line 252), following the same pattern:
```csharp
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.FrameProfEnabled</c>
/// (env var <c>ACDREAM_FRAME_PROF</c>). Toggling here starts/stops the
/// [frame-prof] 5-second report live — no relaunch required.
/// </summary>
public bool FrameProf
{
get => AcDream.Core.Rendering.RenderingDiagnostics.FrameProfEnabled;
set => AcDream.Core.Rendering.RenderingDiagnostics.FrameProfEnabled = value;
}
```
- [ ] **Step 4: Add the DebugPanel checkbox**
Locate the renderer: `grep -rn "ProbeResolve" src/AcDream.UI.ImGui/` — expected: one hit in the DebugPanel renderer drawing a checkbox bound to `vm.ProbeResolve`. Add an identical checkbox line for `vm.FrameProf` labeled `"Frame profiler ([frame-prof])"` adjacent to it, copying the exact checkbox idiom used there.
- [ ] **Step 5: Build + full test suite**
Run: `dotnet build`
Expected: green.
Run: `dotnet test`
Expected: all green (no existing test touches these paths).
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs src/AcDream.UI.ImGui/
git commit -m "feat(pipeline): MP0 - wire FrameProfiler into GameWindow + DebugPanel toggle"
```
---
### Task 5: Baseline capture (user-driven; MP0's deliverable + gate)
**Files:**
- Create: `docs/research/2026-07-XX-mp0-baseline.md` (use the actual capture date)
- [ ] **Step 1: Confirm Release build is green**
Run: `dotnet build -c Release`
Expected: green. Never launch on a red build.
- [ ] **Step 2: Launch with the profiler on (user drives)**
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_FRAME_PROF="1" # NOT ACDREAM_WB_DIAG — mutually exclusive GPU timing
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "mp0-baseline.log"
```
Launch in the background (`run_in_background: true`); the user manages the client lifecycle per CLAUDE.md. Ask the user to run this route, pausing ~30 s (≥5 report lines) in each state:
1. Dense town, standing still, facing the dense view (Holtburg center; Fort Tethana/Arwic if reachable).
2. Dense town, panning the camera continuously.
3. Walking a full town traversal (streaming active).
4. 23 portal hops (the hitch scenario).
5. A dungeon interior walk.
- [ ] **Step 3: Extract the numbers**
Run: `Select-String -Path mp0-baseline.log -Pattern "\[frame-prof\]"`
Expected: one line per 5 s. Map line timestamps/order to the route states (the user says when each state started; correlate by order).
- [ ] **Step 4: Write the baseline report**
Create `docs/research/2026-07-XX-mp0-baseline.md` containing, per route state: cpu_ms p50/p95/p99/max, gpu_ms p50/p95, alloc_kb p50/max, gc counts, stage split (upd/upl/imgui p50/p95) — plus:
- **The attribution verdict:** where do the ~6 dense-town CPU ms actually go (update vs upload vs imgui vs unattributed-render)? Does it confirm the spec §1 split (render-submission dominant)?
- **The gate decision (spec §5):** if attribution confirms the assumed split → proceed to MP1 planning unchanged. If it contradicts (e.g. Update dominates) → amend the spec's phase order BEFORE MP1, in the same session, and say so in this doc.
- The hitch profile: worst max/p99 during portal hops and traversal — this is MP1's "before" number.
- [ ] **Step 5: Commit + update the spec status**
Mark the spec's MP0 row as baseline-captured (one-line edit in `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §5 gate line: append "— PASSED <date>, see baseline doc").
```bash
git add docs/research/2026-07-XX-mp0-baseline.md docs/superpowers/specs/2026-07-05-modern-pipeline-design.md
git commit -m "docs(pipeline): MP0 baseline captured - attribution verdict + gate decision"
```
---
## Post-plan notes for the executor
- **Do not strip `[frame-prof]`** in future probe-cleanup commits — it is permanent apparatus (the flag doc comment says so; so does spec §5).
- The existing `[FRAME-DIAG]` / `[WB-DIAG]` apparatus is untouched by this plan — the distance-degrade handoff still owns its strip decision.
- The roadmap "Modern Pipeline side track" section + milestones-doc freeze-exception paragraph (spec §12) should be added in the same commit as Task 5's baseline doc if not already present.

View file

@ -0,0 +1,221 @@
# MP1a — AcDream.Content Extraction Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extract the GL-free CPU half of the WB mesh/texture pipeline out of `AcDream.App` into a new `AcDream.Content` assembly, so the MP1b bake tool can run it without an OpenGL context — with **zero behavior change** (the client keeps running the exact same code, relocated).
**Architecture:** Mechanical move, not a rewrite. `ObjectMeshManager`'s `Prepare*` family (dat read → mesh extraction → inline texture decode → `ObjectMeshData`) moves verbatim into a new `MeshExtractor` class in `AcDream.Content`; `ObjectMeshManager` keeps its queue/worker/GL-upload lifecycle and delegates. The one GL-domain entanglement is `TextureAtlasManager.TextureKey` (a pure-data struct nested in a GL class) — it gets lifted out first. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §6.1 ("Extraction prerequisite"). MP1b (bake tool + pak) and MP1c (streaming cutover) are separate plans.
**Tech Stack:** .NET 10 class library (no Silk.NET), `BCnEncoder.Net.ImageSharp` 1.1.2 + `SixLabors.ImageSharp` 3.1.12 (the inline DXT decode's packages), `AcDream.Core` project reference (TextureHelpers etc.).
**Binding rules:**
- **Verbatim bodies.** Method bodies move unchanged — same statements, same inline decode (do NOT "clean up" by switching to `SurfaceDecoder`; the two decoders are not proven bit-identical and byte-identity is MP1b's conformance foundation). Only mechanical edits allowed: namespace/using lines, `private``public` on moved members the old class must still reach, field references rehomed onto the new class.
- **The compiler enumerates dependencies.** If moving a method drags in a type, move it if it's GL-free; if a dependency turns out to touch GL (`Silk.NET`, `OpenGLGraphicsDevice`, `GlobalMeshBuffer`, `TextureAtlasManager` beyond the lifted key, `ManagedGL*`), **STOP and report BLOCKED** — do not improvise a redesign.
- **No divergence-register rows** — a pure relocation introduces no behavior deviation. Any observed behavior change is a bug in the move.
- Verified boundary facts (2026-07-05, this worktree): the `Prepare*` region's only GL-domain reference is `TextureAtlasManager.TextureKey` (`ObjectMeshManager.cs:1286,1631,2236,2254`); first real GL call is `UploadGfxObjMeshData` at `:1833`. Line numbers drift — match by symbol.
---
## File map
| File | Action | Responsibility |
|---|---|---|
| `src/AcDream.Content/AcDream.Content.csproj` | Create | GL-free content/asset assembly (net10.0) |
| `src/AcDream.Content/TextureKey.cs` | Create (lift) | The atlas dedup key struct, moved out of `TextureAtlasManager` |
| `src/AcDream.Content/ObjectMeshData.cs` | Create (move) | The boundary records: `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, `VertexPositionNormalTexture`, and sibling GL-free data records from `ObjectMeshManager.cs`'s data region |
| `src/AcDream.Content/MeshExtractor.cs` | Create (move) | The `Prepare*` family + its private helpers/caches, verbatim |
| `src/AcDream.Content/` (as compiler demands) | Create (move) | `IDatReaderWriter.cs`, `DatCollectionAdapter.cs`, `AcSurfaceMetadata.cs`, `AcSurfaceMetadataTable.cs`, `EdgeLineBuilder.cs`, `GeometryUtils.cs` — GL-free deps of the extractor (move only the ones the build actually requires) |
| `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` | Modify | Keeps queue/worker/upload lifecycle; delegates mesh prep to `MeshExtractor`; loses the moved types/methods |
| `src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs` | Modify | Uses the lifted `TextureKey` |
| `src/AcDream.App/AcDream.App.csproj` | Modify | Add `ProjectReference` to Content |
| `AcDream.slnx` | Modify | Register the new project |
| `docs/architecture/worldbuilder-inventory.md` | Modify | Record the relocation (Code Structure Rule 2 requires the inventory note for project-reference changes) |
`AcDream.Core` is untouched. `AcDream.Content` references Core; App references Content; Core must NOT reference Content.
---
### Task 1: Create the AcDream.Content project
**Files:**
- Create: `src/AcDream.Content/AcDream.Content.csproj`
- Modify: `AcDream.slnx`, `src/AcDream.App/AcDream.App.csproj`
- [ ] **Step 1: Project file**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<!-- MP1a: the inline DXT decode moved from ObjectMeshManager uses these
exact versions (mirror AcDream.App.csproj — keep in lockstep). -->
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
</Project>
```
NOTE: `Chorizite.DatReaderWriter` flows transitively via AcDream.Core. If the moved code fails to resolve DRW types, add the explicit `PackageReference` pinned to the same version as Core (2.1.7) rather than bumping anything.
- [ ] **Step 2: Register in `AcDream.slnx`** — add the project entry following the existing `<Project Path="src/AcDream.Core/AcDream.Core.csproj" />` pattern (open the file, copy the shape).
- [ ] **Step 3: App references Content** — in `src/AcDream.App/AcDream.App.csproj`, add alongside the existing ProjectReferences:
```xml
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
```
- [ ] **Step 4: Build**
Run: `dotnet build`
Expected: green (empty project compiles, references resolve).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Content/ AcDream.slnx src/AcDream.App/AcDream.App.csproj
git commit -m "feat(pipeline): MP1a - AcDream.Content assembly scaffold"
```
---
### Task 2: Lift TextureKey out of TextureAtlasManager
**Files:**
- Create: `src/AcDream.Content/TextureKey.cs`
- Modify: `src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs` (delete the nested struct, use the lifted one)
- Modify: every `TextureAtlasManager.TextureKey` reference site (find with `grep -rn "TextureAtlasManager.TextureKey" src/`)
- [ ] **Step 1: Move the struct.** Copy the nested `TextureKey` struct out of `TextureAtlasManager` VERBATIM (fields, equality/GetHashCode if present, doc comments) into:
```csharp
namespace AcDream.Content;
// MP1a (2026-07-05): lifted verbatim out of TextureAtlasManager (GL class)
// so the GL-free mesh-extraction path (MeshExtractor / ObjectMeshData) can
// reference the atlas dedup key without a Silk.NET dependency. Field set,
// equality semantics, and hash behavior are UNCHANGED.
public struct TextureKey
{
// ... the verbatim body from TextureAtlasManager.TextureKey ...
}
```
(The plan intentionally does not restate the body — copy it exactly from the source; it is pure data: SurfaceId/PaletteId/Stippling/IsSolid per the boundary survey.)
- [ ] **Step 2: Update all reference sites.** `TextureAtlasManager.TextureKey``TextureKey` with `using AcDream.Content;`. Keep a `using` alias only if a name collision appears (none expected).
- [ ] **Step 3: Build + full test suite**
Run: `dotnet build` then `dotnet test`
Expected: green / all passing (this is a pure type relocation).
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor(pipeline): MP1a - lift TextureKey out of the GL atlas class"
```
---
### Task 3: Move the boundary data records
**Files:**
- Create: `src/AcDream.Content/ObjectMeshData.cs`
- Modify: `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (types removed), all reference sites (`grep -rn "ObjectMeshData\|VertexPositionNormalTexture\|TextureBatchData\|MeshBatchData" src/ tests/`)
- [ ] **Step 1: Move these types from `ObjectMeshManager.cs`'s data region into `AcDream.Content` (namespace `AcDream.Content`), VERBATIM:** `VertexPositionNormalTexture` (the 32-byte GPU vertex — this is the pak layout target, do not alter layout/stride), `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, plus any small GL-free records the compiler then demands (e.g. setup-part / emitter-info records referenced by `ObjectMeshData` fields).
**Explicitly stays in App:** `ObjectRenderData` and `ObjectRenderBatch` (they hold `TextureAtlasManager Atlas` — a GL class) and anything else the compiler shows touching GL. If `ObjectMeshData` itself turns out to hold a GL-typed field the survey missed, STOP and report BLOCKED.
- [ ] **Step 2: Update reference sites** (`using AcDream.Content;` in the App files + any tests).
- [ ] **Step 3: Build + full test suite** — green / all passing.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "refactor(pipeline): MP1a - ObjectMeshData family moved to AcDream.Content"
```
---
### Task 4: Extract MeshExtractor (the Prepare* family)
**Files:**
- Create: `src/AcDream.Content/MeshExtractor.cs`
- Move as the compiler demands: `IDatReaderWriter.cs`, `DatCollectionAdapter.cs`, `AcSurfaceMetadata.cs`, `AcSurfaceMetadataTable.cs`, `EdgeLineBuilder.cs`, `GeometryUtils.cs` from `src/AcDream.App/Rendering/Wb/``src/AcDream.Content/` (verbatim, namespace change only)
- Modify: `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`
- [ ] **Step 1: Create the extractor class.** New class in Content:
```csharp
namespace AcDream.Content;
/// <summary>
/// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager —
/// dat read → polygon walk → vertex/index build → inline texture decode →
/// <see cref="ObjectMeshData"/>. Extracted VERBATIM so the MP1b bake tool
/// and the live client run the SAME extraction code (byte-identical output
/// is the pak conformance foundation). ObjectMeshManager (App) retains the
/// queue/worker lifecycle and all GL upload; it delegates here.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.1.
/// </summary>
public sealed class MeshExtractor
{
// Constructor takes the extraction dependencies the moved methods used
// as ObjectMeshManager fields — expected from the boundary survey:
// IDatReaderWriter dats, AcSurfaceMetadataTable surfaceMetadata
// plus the CPU-side caches that belong with extraction:
// the ThreadLocal<BcDecoder>, the decoded-texture cache, bounds cache.
// Let the compiler produce the exact list — add fields here, do not
// leave them behind in App if only Prepare* uses them.
}
```
- [ ] **Step 2: Move the methods VERBATIM onto `MeshExtractor`, making the entry points `public`:** `PrepareMeshData`, `PrepareGfxObjMeshData`, `PrepareEnvCellMeshData`, `PrepareCellStructMeshData`, `PrepareSetupMeshData`, `PrepareCellStructEdgeLineData`, and the private helpers they call (`CollectParts`, `ComputeBounds`, `CollectDrawingBspPolygonIds`, `BuildPolygonIndices`, `BuildCellStructPolygonIndices`, `CollectEmittersFromScript`, plus whatever else the compiler names). Move the fields/caches only they use. Bodies unchanged.
- [ ] **Step 3: Delegate from ObjectMeshManager.** It constructs one `MeshExtractor` in its constructor (passing its dat reader + metadata table) and every former `Prepare*` call site becomes `_extractor.Prepare*`. The staged-queue enqueue, worker pool, retry, and `Dispose` quiesce logic stay in `ObjectMeshManager` untouched (the MP0-investigated teardown discipline is a runtime concern; the bake tool will drive the extractor with its own simple loop in MP1b).
- [ ] **Step 4: Move the support files** the compiler demands (list above), namespace `AcDream.Content`, bodies verbatim. `Building.cs` is NOT in scope (its GL-query fields keep it in App for now).
- [ ] **Step 5: Build + FULL test suite**
Run: `dotnet build` then `dotnet test`
Expected: green / all passing. The full suite is the behavior gate — no test may change.
- [ ] **Step 6: Update the inventory doc.** In `docs/architecture/worldbuilder-inventory.md`, add an MP1a note under the extracted-code section: the CPU mesh-extraction half now lives in `src/AcDream.Content/` (list the moved files), App retains GL upload/lifecycle, Core untouched, reason = MP1 bake tool needs GL-free extraction (spec §6.1).
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move)"
```
---
### Task 5: Launch smoke (user-gated)
- [ ] **Step 1:** `dotnet build -c Release` — green.
- [ ] **Step 2:** Launch per the standard live-launch recipe (CLAUDE.md), user in-world for ~2 minutes: Holtburg buildings + an interior + a dungeon teleport. Acceptance: world renders identically — no missing/white/wrong geometry or textures (the #105-class signature would indicate the move broke a per-frame driver; per `feedback_extraction_perframe_drivers` the lifecycle stayed in App precisely to avoid this, so any anomaly = STOP and investigate the move).
- [ ] **Step 3:** On user confirmation, update the roadmap Track MP table (MP1a slice shipped) and commit.
```bash
git add docs/plans/2026-04-11-roadmap.md
git commit -m "docs(pipeline): MP1a shipped - Content extraction user-gated"
```

View file

@ -0,0 +1,113 @@
# MP1b — Pak Format + acdream-bake + PakReader Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A versioned pak file containing every `ObjectMeshData` the client's decode workers would otherwise produce at runtime (GfxObj / Setup / EnvCell mesh+texture payloads), an offline `acdream-bake` CLI that produces it by driving MP1a's `MeshExtractor`, a zero-copy mmap `PakReader`, and an equivalence test proving pak round-trip output is identical to live extraction.
**Architecture:** The bake tool and the client run the SAME extraction code (`AcDream.Content.MeshExtractor`, extracted verbatim in MP1a) — the pak stores its serialized output, so equivalence is by construction and verified by test. Nothing in the client consumes the pak yet (that is MP1c); MP1b has **no user visual gate** — its gates are round-trip tests, the dat-gated equivalence suite, and a timed full bake. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §6.26.5 including the 2026-07-05 amendments (v1 textures = RGBA8 as produced; `PakReader` lives in `AcDream.Content`, not Core).
**Scope discipline (v1 pak content):** ONLY the `ObjectMeshData` asset classes — this is what the decode workers produce during the 211 ms / 75.7 MB teleport storms (the MP0 baseline's target). Terrain blittables, flattened physics BSPs, scenery instance lists, and degrade tables are LATER slices (MP1c+/MP2/MP4) — do not add them.
**Tech Stack:** .NET 10; `System.IO.MemoryMappedFiles` + `MemoryMarshal` for the reader; plain `BinaryWriter`-style serialization (little-endian, versioned); xunit in a new `tests/AcDream.Content.Tests`.
---
## File map
| File | Action | Responsibility |
|---|---|---|
| `tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj` | Create | Content-layer test project (rule 6), registered in `AcDream.slnx` |
| `src/AcDream.Content/Pak/PakKey.cs` | Create | 64-bit asset key: `type:u8 \| fileId:u32 \| reserved:u24` |
| `src/AcDream.Content/Pak/PakFormat.cs` | Create | Header/TOC binary layout constants + structs |
| `src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs` | Create | `ObjectMeshData` ⇄ bytes, versioned, deterministic |
| `src/AcDream.Content/Pak/PakWriter.cs` | Create | Streams blobs to disk, writes TOC + header |
| `src/AcDream.Content/Pak/PakReader.cs` | Create | mmap, O(log n) TOC lookup, span access, typed reads |
| `src/AcDream.Bake/AcDream.Bake.csproj` + `Program.cs` | Create | The CLI: enumerate ids → parallel extract → pak |
| `tests/AcDream.Content.Tests/*.cs` | Create | Round-trip + format + dat-gated equivalence tests |
| `AcDream.slnx` | Modify | Register both new projects |
`AcDream.Bake` references `AcDream.Content` (+ transitively Core/DRW). No Silk.NET anywhere in either new project — both csproj files get `TreatWarningsAsErrors` + `LangVersion latest` like Content.
## Format v1 (normative)
All integers little-endian. File layout: `[Header][Blob region][TOC]` (TOC last so the writer streams blobs without knowing counts up front).
**Header (64 bytes, fixed):**
```
offset size field
0 4 magic 'ACPK' (0x4B504341)
4 4 formatVersion = 1
8 4 portalIteration (DatCollection.Portal.Iteration)
12 4 cellIteration
16 4 highResIteration
20 4 languageIteration
24 8 tocOffset (u64)
32 4 tocCount (u32)
36 4 bakeToolVersion = 1
40 24 reserved (zero)
```
**PakKey (u64):** `((ulong)type << 56) | ((ulong)fileId << 24)` — low 24 bits reserved (variant/zero in v1). `type` enum (u8): `GfxObjMesh = 1`, `SetupMesh = 2`, `EnvCellMesh = 3`. (`fileId` is the dat id: `0x01xxxxxx` GfxObj, `0x02xxxxxx` Setup, cell ids for EnvCells.)
**TOC entry (24 bytes each):** `key u64, offset u64, length u32, crc32 u32` — entries sorted ascending by key (binary search); `crc32` over the blob bytes (corruption tripwire; reader verifies lazily on first access of each blob, logs + treats as missing on mismatch).
**Blobs:** each 64-byte aligned (writer pads). Content = `ObjectMeshDataSerializer` output (below). Uncompressed in v1 (spec: mmap + page cache; LZ4 is a recorded future option).
**Serialized ObjectMeshData v1:** field-by-field, versioned by the header's formatVersion. Layout rules the implementer follows: primitives raw LE; arrays as `count:i32 + payload`; blittable arrays (`VertexPositionNormalTexture[]`, `ushort[]` indices, `byte[]` texture data) written via `MemoryMarshal.AsBytes` bulk copy; strings absent (none in the type); **the `TextureBatches` dictionary is written sorted by key tuple `(Width, Height, Format)` and each inner list in original order** — bakes must be byte-reproducible run-to-run; nullable fields as `present:byte + value`. Enumerate the actual fields from `src/AcDream.Content/ObjectMeshData.cs` (the moved records: `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, `StagedEmitter`, `TextureKey`, `UploadPixelFormat/Type`, plus the DRW `Sphere`/bounding types stored as their float components) — serialize EVERY field; if a field's type resists obvious serialization, STOP and report rather than skipping it (a skipped field is a silent equivalence failure later).
---
### Task 1: Test project scaffold
- [ ] Create `tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj` — copy the shape of `tests/AcDream.App.Tests/AcDream.App.Tests.csproj` (xunit, net10.0), with a `ProjectReference` to `..\..\src\AcDream.Content\AcDream.Content.csproj`. Register in `AcDream.slnx`.
- [ ] Add a trivial smoke test (`PakKeyTests` placeholder is fine — it becomes real in Task 2).
- [ ] `dotnet build` + `dotnet test tests/AcDream.Content.Tests` green.
- [ ] Commit: `test(pipeline): MP1b - AcDream.Content.Tests scaffold`
### Task 2: PakKey + header/TOC primitives (TDD)
- [ ] **Failing tests first** (`PakKeyTests`, `PakFormatTests`): key compose/decompose round-trip for each asset type incl. max fileId `0xFFFFFFFF`; key ordering matches (type, fileId) ordering; header write→read round-trip preserves every field; TOC entry write→read round-trip; TOC binary size = 24 bytes/entry exactly.
- [ ] Implement `PakKey` (static compose/decompose + the `PakAssetType` enum) and `PakFormat` (header struct + read/write over `Span<byte>`/`Stream`, TOC entry struct + read/write) per the normative layout above.
- [ ] Tests green; commit: `feat(pipeline): MP1b - pak key + header/TOC primitives`
### Task 3: ObjectMeshDataSerializer (TDD)
- [ ] **Failing tests first** (`ObjectMeshDataSerializerTests`): build synthetic `ObjectMeshData` instances covering — empty object; vertices+indices only; multiple texture batches across multiple `(W,H,Format)` groups; setup with parts; emitters present; nullable fields both present and absent; non-empty `EdgeLines`. Round-trip each and deep-compare EVERY field (write a `ObjectMeshDataEquality` test helper that compares field-by-field with clear failure messages — this helper is also Task 6's comparator). Plus: serializing the same instance twice yields byte-identical output (determinism), and dictionaries inserted in different orders yield byte-identical output (sorted-key rule).
- [ ] Implement `ObjectMeshDataSerializer.Write(ObjectMeshData, Stream)` + `Read(ReadOnlySpan<byte>) → ObjectMeshData` per the layout rules.
- [ ] Tests green; commit: `feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)`
### Task 4: PakWriter + PakReader (TDD)
- [ ] **Failing tests first** (`PakRoundTripTests`): write a pak with N synthetic blobs (via Task 3 serializer) to a temp file → open with `PakReader` → header fields match; every key found; blob spans deserialize to deep-equal objects; missing key returns false; corrupted blob (flip one byte) → crc mismatch → treated as missing + logged once; blobs are 64-byte aligned; TOC binary-search lookup verified against a linear scan for all keys.
- [ ] Implement `PakWriter` (streaming: write header placeholder → blobs with padding → TOC → seek back and finalize header) and `PakReader` (`MemoryMappedFile` + `ReadOnlySpan<byte>` accessor, lazily-verified crc set, `TryReadObjectMeshData(PakKey, out ObjectMeshData)`). No locks; document the immutability argument on the class.
- [ ] Tests green; commit: `feat(pipeline): MP1b - PakWriter + mmap PakReader`
### Task 5: acdream-bake CLI
- [ ] Create `src/AcDream.Bake/` console project (register in slnx). Arguments: `--dat-dir <path>` (required), `--out <file>` (default `acdream.pak` next to the dats), `--ids <hexlist>` and `--landblocks <hexlist>` (optional dev filters), `--threads <n>` (default `Environment.ProcessorCount`).
- [ ] Wiring: `DatCollection(datDir, Read)``DatCollectionAdapter` → one `MeshExtractor` per worker OR one shared (extractor is documented 4-worker-safe — shared is fine; sink = a `ConcurrentBag` collector whose entries are serialized under their own GfxObj keys, deduplicated by id).
- [ ] Enumeration: GfxObj ids + Setup ids via `Portal.GetAllIdsOfType<...>` (mirror how `ObjectMeshManager`/CLI tools enumerate — check `src/AcDream.Cli/Program.cs` for the idiom); EnvCell ids by walking landblock `LandBlockInfo.NumCells` per the streamer's hydration path (find the idiom in `GameWindow.BuildPhysicsDatBundle` / the EnvCell loader — read, don't invent).
- [ ] Parallel extract (`Parallel.ForEach`, `--threads`), progress line every 5 s (`baked/total, failures, elapsed, ETA`), failures logged per-id and counted, never fatal (a malformed dat entry skips that id — same as the runtime's behavior).
- [ ] Write pak via `PakWriter`; stamp the four dat iterations into the header; print a summary (counts per type, failures, output size, elapsed).
- [ ] Smoke: `dotnet run --project src/AcDream.Bake -- --dat-dir "%USERPROFILE%\Documents\Asheron's Call" --ids 0x01000001,0x02000001 --out scratch-test.pak` produces a valid pak that `PakReader` opens (assert via a quick console read-back or a test). Delete the scratch file.
- [ ] Commit: `feat(pipeline): MP1b - acdream-bake CLI`
### Task 6: Dat-gated equivalence suite
- [ ] `PakEquivalenceTests` in Content.Tests, skipping cleanly when dats are absent (copy the skip pattern from `tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests.cs`). For a fixture id set (≥10 GfxObjs incl. known-tricky ones — grep the test tree for ids used by `Issue119UpNullGfxObjDumpTests`/`StipplingSurfaceEquivalenceTests` and reuse them; ≥3 Setups; ≥5 EnvCells from the Holtburg landblock `0xA9B4`): run `MeshExtractor` live AND bake the same ids to a temp pak → read back → deep-compare with the Task 3 equality helper. Any field mismatch = failure naming the field.
- [ ] Tests green (on this machine, dats present); full `dotnet test` green.
- [ ] Commit: `test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated)`
### Task 7: Full bake run + gate record (coordinator/user machine)
- [ ] `dotnet build -c Release` green; run the full bake against the real dats (no filters), Release.
- [ ] Record in `docs/research/2026-07-XX-mp1b-bake-report.md`: total assets per type, failures (each listed), wall time, pak size, and the equivalence-suite result. Gate: equivalence green + bake completes + failure list reviewed (a failure on an id the runtime CAN decode = bug; a failure matching runtime behavior = recorded as expected).
- [ ] Update roadmap Track MP row (MP1b shipped); commit both docs.
---
## Post-plan notes
- No divergence-register rows: nothing consumed at runtime changes in MP1b.
- The pak file itself is user-machine output — never committed; add `*.pak` to `.gitignore` in the same commit as Task 5.
- MP1c (the cutover: decode workers try `PakReader` first, stale-pak refusal UX, hitch re-measure vs the 211 ms baseline) is the next plan; its "before" numbers are already in the MP0 baseline doc.

View file

@ -0,0 +1,908 @@
# Player physics per-frame loop — verbatim rebuild — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port retail `CPhysicsObj::UpdateObjectInternal`'s velocity/collision chain verbatim into acdream's player per-frame loop so a blocked jump into a monster crowd **bleeds its velocity and glides/lands like retail** (fixes #182's airborne "stuck in the falling animation" regression + halves the general crowd jam), keeping the already-faithful transition internals as the collision primitive.
**Architecture:** The retail per-frame chain is `UpdateObject` (dt sub-step driver) → `UpdateObjectInternal` (integrate candidate → `transition` sweep → commit + `cached_velocity`) → `SetPositionInternal` (commit resolved frame + contact/walkable/sliding flags + `handle_all_collisions`) → `handle_all_collisions` (velocity reflect OR zero, driven by `frames_stationary_fall`). The velocity "bleed on block" is **not** the `cached_velocity` field (a separate reporting/DR value) — it is the `frames_stationary_fall` (fsf) counter: `validate_transition` increments it 0→1→2→3 when the sphere fails to advance and at fsf≥2 manufactures an upward contact plane; `handle_all_collisions` **zeros the velocity when fsf > 1**. acdream reflects but never runs the fsf path (registered as TS-3), so a blocked jump reflects a sliver of +Z and hangs. This rebuild completes the fsf round-trip in the kept transition internals and ports the `SetPositionInternal`/`handle_all_collisions` consumer into the player loop, replacing acdream's ad-hoc reflect/land block.
**Tech Stack:** C# / .NET 10, xUnit. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (named PDB decomp). Measurement: `ACDREAM_CAPTURE_RESOLVE` JSONL capture + a new Python histogram classifier. A/B target (retail cdb, `tools/cdb/retail-crowd-jump3.cdb`): **~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck.**
---
## 0. Refinement of the design spec (read before starting)
This plan **refines** `docs/superpowers/specs/2026-07-07-player-physics-update-verbatim-rebuild-design.md`. The design's §7 explicitly deferred verifying the mechanism to "the writing-plans step"; that verification (reading `handle_all_collisions` 0x00514780, `UpdatePhysicsInternal` 0x00510700, and the fsf lifecycle end-to-end) changed the slicing:
| Design said | Verified truth (decomp + capture) | Consequence |
|---|---|---|
| Slice 1 = the `cached_velocity = (resolvedold)/dt` model bleeds velocity → airborne-stuck→0 | `cached_velocity` is a **separate reporting field** read only by `get_velocity` (network/DR/camera), never fed to the integrator (`m_velocityVector`). It does **not** bleed the jump. | The airborne-stuck fix lives in `handle_all_collisions` (`fsf>1 → v=0`), which needs the **fsf round-trip** first. Slices reorder: fsf substrate (Slice 1) → `handle_all_collisions` consumer (Slice 2, the design's "Slice 2", now the load-bearing fix). |
| §7 Q1: does retail reflect, or is velocity purely movement-derived? | Retail does **both**: `fsf≤1` → reflect `v += -(v·n)(elasticity+1)·n`; `fsf>1``v=0`. Velocity is `m_velocityVector`-integrated. | Keep `PhysicsBody.Velocity` as `m_velocityVector`; **add a separate `CachedVelocity`** field; do not collapse them. |
| §7 Q2: which `SetPositionInternal` overload | `0x00515330` (single `CTransition` arg) — confirmed from `UpdateObjectInternal:283696`. | Port that overload's contact/walkable/sliding + `handle_all_collisions` sequence. |
| §7 Q3: is the general ground-jam explained by the velocity model, or a second divergence? | Measure after Slice 2. Candidate residual = **TS-4** (BSPQuery Path-6 steep persisted sliding-normal / #137 anti-parallel-absorb) — in the *kept* internals. | Slice 3, only if <~78% OK. Do not touch TS-4 pre-measurement. |
**Out of scope (kept — already faithful, per the user "keep everything faithful"):** the transition INTERNALS below `ResolveWithTransition` — BSPQuery, the CSphere/CylSphere collision families (incl. the #182 port), cell membership, terrain, streaming. The ONE exception is completing the fsf stub inside `ValidateTransition` — that stub **is** "the full physics port" TS-3 was explicitly deferred to; completing it is finishing a known gap, not re-porting working code. **`calc_friction` is out of scope** — it carries an orthogonal threshold divergence (retail 0.25 vs acdream 0.0) whose naive fix regresses walking (L.3c); the fsf fix does not require it. **#182 stays the base** (user decision).
---
## 1. The verified retail chain (the port source — cite these in code comments)
All addresses/pc from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
### `UpdateObjectInternal(dt)` — 0x005156b0, pc:283611
```
if not active (transient_state high bit clear): update particles/scripts; return
if cell == 0: return
jumped_this_frame = 0
candidate = identity frame
UpdatePositionInternal(dt, &candidate) // integrate → candidate
if part_array has spheres AND candidate.origin != m_position.origin: // moved
(set candidate heading from velocity/motion)
trans = transition(m_position, candidate, 0) // COLLISION SWEEP (== ResolveWithTransition)
if trans == null:
set_frame(candidate); cached_velocity = 0
else:
cached_velocity = (trans.sphere_path.curr_pos m_position) / dt // 0x005158cb-005158ff
SetPositionInternal(trans) // 0x00515330
else: // no spheres OR didn't move
set_frame(candidate); cached_velocity = 0
// tail: detection / target / movement.UseTime / part.HandleMovement / position.UseTime
```
### `UpdatePositionInternal(dt, &newFrame)` — 0x00512c30, pc:280817
```
delta = identity
part_array.Update(dt, &delta) // animation root motion → delta
if not OnWalkable: delta.translation *= 0 // airborne: no anim translation
else: delta.translation *= m_scale
position_manager.adjust_offset(&delta, dt) // sticky
newFrame = m_position.frame ∘ delta // combine
UpdatePhysicsInternal(dt, &newFrame) // velocity + gravity integration
process_hooks()
```
### `UpdatePhysicsInternal(dt, &newFrame)` — 0x00510700, pc:278460
```
velMag2 = |m_velocityVector|²
if velMag2 > 0:
if velMag2 > 2500 (=50²): m_velocityVector = normalize(m_velocityVector)*50
calc_friction(dt, velMag2)
if (velMag2 0.0625 (=0.25²)) <= 0.0002 (F_EPSILON): m_velocityVector = 0 // NOT gated on OnWalkable
newFrame.origin += m_velocityVector*dt + 0.5*accel*dt²
else if movement_manager == null:
if OnWalkable: clear Active bit
m_velocityVector += accel*dt // UNCONDITIONAL (both branches)
newFrame = grotate(newFrame, omega*dt)
```
### `calc_acceleration()` — 0x00510950, pc:278533
```
if Contact(0x1) AND OnWalkable(0x2) AND !(state & 0x800000 SLEDDING): accel=0; omega=0; return
if !(state & 0x400 GRAVITY): accel=0; return
accel = (0,0, gravity(-9.8))
```
### `SetPositionInternal(trans)` — 0x00515330, pc:283399
```
curr_cell = trans.sphere_path.curr_cell
if curr_cell == 0: prepare_to_leave_visibility; store_position; GotoLostCell; clear Active; return 1
if this.cell != curr_cell: change_cell(curr_cell) else: update objcell_id (+children)
set_frame(trans.sphere_path.curr_pos.frame) // COMMIT resolved position
contact_plane = trans.collision_info.contact_plane
contact_plane_cell = trans.collision_info.contact_plane_cell_id
Contact bit(0x1) = trans.collision_info.contact_plane_valid ; calc_acceleration()
Water bit(0x8) = trans.collision_info.contact_plane_is_water
if Contact == 0: clear OnWalkable(0x2); if was OnWalkable: movement.LeaveGround(); calc_acceleration()
else: set_on_walkable(contact_plane.N.z >= floor_z(0.6642))
sliding_normal = trans.collision_info.sliding_normal
Sliding bit(0x4) = trans.collision_info.sliding_normal_valid
handle_all_collisions(trans.collision_info, PREV_Contact, PREV_OnWalkable) // prev = transient_state captured at entry
[if HAS_PHYSICS_BSP(0x10000): calc_cross_cells; else shadow-cell updates]
return 1
```
### `handle_all_collisions(ci, prev_contact, prev_on_walkable)` — 0x00514780, pc:282647
```
should_reflect = !(prev_on_walkable AND OnWalkable AND !(state & SLEDDING)) // var_10_1
[report collision-start for each collided object; report_collision_end; environment-collision report]
fsf = ci.frames_stationary_fall
if fsf <= 1:
if should_reflect AND ci.collision_normal_valid AND !(state & 0x20000 INELASTIC):
dot = m_velocityVector · ci.collision_normal
if dot < 0: m_velocityVector += -(dot*(elasticity+1)) * ci.collision_normal // REFLECT
elif should_reflect AND ci.collision_normal_valid: // INELASTIC
m_velocityVector = 0
else: // fsf > 1
m_velocityVector = 0 // ZERO (the bleed)
// encode fsf → transient bits:
if fsf == 1: transient_state |= 0x10 (StationaryFall)
elif fsf == 2: transient_state |= 0x20 (StationaryStop)
elif fsf == 3: transient_state |= 0x40 (StationaryStuck)
else (fsf==0): transient_state &= ~(0x10|0x20|0x40)
return
```
### fsf counter in `validate_transition` — 0x0050aa70, pc:272625-272656
```
if ci.collision_normal_valid: ci.set_sliding_normal(ci.collision_normal)
if (object_info.state & 4)==0 AND (target_object.state & 0x400 GRAVITY)!=0: // mover-not-frozen AND object has gravity
if moved: ci.frames_stationary_fall = 0 // advanced → reset
else:
if fsf == 0: fsf = 1
elif fsf == 1: fsf = 2
else: fsf = 3 // fsf >= 2 → manufacture UP contact
up = (0,0,1)
d = radius (global_sphere.center · up) // plane through sphere bottom
ci.set_contact_plane(up, water=0); ci.contact_plane_cell_id = check_pos.objcell_id
if (object_info.state & 1)==0: // not already Contact
ci.set_collision_normal(up); ci.collided_with_environment = 1
```
### fsf seed in `transition` — 0x00512dc0, pc:280940-280947
```
after init_path:
if transient_state & 0x40: fsf = 3
elif transient_state & 0x20: fsf = 2
elif transient_state & 0x10: fsf = 1
```
### `UpdateObject(dt)` sub-step driver — 0x00515d10, pc:283960
```
dt = cur_time update_time
if dt <= F_EPSILON: update_time = cur_time; return
if dt > 2.0 (HugeQuantum): update_time = cur_time; return
while dt > MaxQuantum: UpdateObjectInternal(MaxQuantum); dt -= MaxQuantum
if dt > 0: UpdateObjectInternal(dt)
update_time = cur_time
```
(acdream already inlines this dt-substep as `_physicsAccum` in `PlayerMovementController` — kept as-is; the L.5 MinQuantum gate is a documented pre-existing adaptation, out of scope.)
---
## 2. acdream current state (the seam being rebuilt)
- **Per-frame method:** `PlayerMovementController.Update(float dt, MovementInput input)``src/AcDream.App/Input/PlayerMovementController.cs:542-1302`. Caller: `GameWindow.cs:8454`.
- The retail `UpdateObjectInternal` chain is currently **spread** across: §4 integrate (`:820-877`, `_body.UpdatePhysicsInternal(tickDt)` integrating `Position` **in-place**), §5 resolve (`:879-916`, `_physics.ResolveWithTransition(preIntegratePos, postIntegratePos, …)`), apply (`:931-947`), the ad-hoc reflect block (`:949-1056`), and the landing/airborne block (`:1058-1109`).
- `PhysicsBody` (`src/AcDream.Core/Physics/PhysicsBody.cs`): single `Velocity` field (plays `m_velocityVector`), `calc_acceleration`/`UpdatePhysicsInternal`/`calc_friction`/`set_velocity` ports present. **No `CachedVelocity`.** `TransientStateFlags` = Contact/OnWalkable/Sliding/Active only (**no 0x10/0x20/0x40**). `UpdatePhysicsInternal` has a divergence: the small-velocity-zero at `:503-504` is gated on `OnWalkable` (retail is ungated).
- `ResolveResult` (`src/AcDream.Core/Physics/ResolveResult.cs:21`): `readonly record struct` with `Position, CellId, IsOnGround, CollisionNormalValid, CollisionNormal, Ok`. Rich state (ContactPlane, SlidingNormal) flows back through the passed `body` side-effect (`PhysicsEngine.cs:999-1015` seed-in, `:1056-1106` write-out). **`FramesStationaryFall` is not surfaced.**
- `Transition.ValidateTransition(TransitionState)``src/AcDream.Core/Physics/TransitionTypes.cs:4575`. fsf stub at `:4596` (`// moved = true (FramesStationaryFall deferred…)`). "moved" analog = `sp.CheckPos != sp.CurPos` (`:4581`). `CollisionInfo.FramesStationaryFall` field at `:245` (dead). All other retail COLLISIONINFO fields have counterparts.
- **Apparatus:** `PhysicsResolveCapture` (`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`), toggled by `ACDREAM_CAPTURE_RESOLVE`, IsPlayer-filtered at `PhysicsEngine.cs:946`. Baseline capture: `acdream-crowd-resolve.jsonl` (repo root, 41832 lines). No histogram classifier exists yet.
---
## 3. File structure
| File | Change |
|---|---|
| `tools/analyze_resolve_capture.py` | **Create.** Histogram classifier for `ACDREAM_CAPTURE_RESOLVE` JSONL → OK/partial/stuck/airborne-stuck buckets over move-intent records. |
| `src/AcDream.Core/Physics/PhysicsBody.cs` | Add `StationaryFall/Stop/Stuck` flags; add `FramesStationaryFall` + `CachedVelocity` fields; remove the OnWalkable gate on the small-velocity-zero. |
| `src/AcDream.Core/Physics/TransitionTypes.cs` | Un-stub the fsf ladder in `ValidateTransition` (`:4575`); add fsf increment/reset + fsf≥2 UP-plane manufacture. |
| `src/AcDream.Core/Physics/PhysicsEngine.cs` | `ResolveWithTransition`: seed `ci.FramesStationaryFall` from `body` transient bits at entry; write `ci.FramesStationaryFall → body.FramesStationaryFall` at exit. |
| `src/AcDream.App/Input/PlayerMovementController.cs` | Replace the ad-hoc reflect/land block (`:949-1109`) with the ported `SetPositionInternal`+`handle_all_collisions` sequence; compute `CachedVelocity`. |
| `src/AcDream.Core/Physics/PhysicsObjUpdate.cs` | **Create.** Static/pure port of `SetPositionInternal` + `handle_all_collisions` operating on a `PhysicsBody` + a resolve outcome, unit-testable in Core without the App layer. |
| `docs/architecture/retail-divergence-register.md` | Retire TS-3; amend AD-25 (split off the player half); add any new adaptation rows. |
| `tests/AcDream.Core.Tests/Physics/*` | New conformance tests: fsf ladder, `handle_all_collisions` reflect/zero, crowd-jump replay against the capture. |
---
## Task 1: Author the resolve-capture histogram classifier (Slice 0 — measurement)
**Files:**
- Create: `tools/analyze_resolve_capture.py`
- [ ] **Step 1: Write the classifier**
Record schema (camelCase JSON, one per line): `input.{currentPos,targetPos,cellId,...}`, `bodyBefore` / `bodyAfter` (`PhysicsBodySnapshot`, incl. `velocity`, `slidingNormal`, `transientState`), `result.{position,cellId,isOnGround,collisionNormalValid,collisionNormal}`. Vector3 = `{x,y,z}`.
```python
#!/usr/bin/env python3
"""Classify ACDREAM_CAPTURE_RESOLVE JSONL into OK / partial / stuck / airborne-stuck.
Buckets (move-intent records only, i.e. targetPos != currentPos):
OK reached target: dist(result.position, targetPos) <= EPS_REACH
partial advanced short: moved > EPS_MOVE and not OK
stuck reverted: moved <= EPS_MOVE (requested motion, none delivered)
airborne-stuck subset of stuck: bodyBefore airborne with jump velocity into a
near-horizontal collision normal (the falling-animation wedge)
Retail target (retail-crowd-jump3.cdb): ~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck.
"""
import sys, json, math
EPS_REACH = 0.02 # 2 cm — "reached target"
EPS_MOVE = 0.01 # 1 cm — "advanced at all"
JUMP_VZ = 5.0 # bodyBefore.velocity.z above this = a jump/airborne launch
HORIZ_NZ = 0.5 # |collisionNormal.z| below this = near-horizontal (creature side)
def d(a, b):
return math.sqrt((a["x"]-b["x"])**2 + (a["y"]-b["y"])**2 + (a["z"]-b["z"])**2)
def classify(rec):
i = rec["input"]
if d(i["targetPos"], i["currentPos"]) <= EPS_MOVE:
return None # zero-motion rest tick — not a move-intent record
r = rec["result"]
moved = d(r["position"], i["currentPos"])
reached = d(r["position"], i["targetPos"]) <= EPS_REACH
if reached:
return "ok"
if moved > EPS_MOVE:
return "partial"
# reverted / stuck
bb = rec.get("bodyBefore") or {}
vel = bb.get("velocity") or {"x":0,"y":0,"z":0}
n = r.get("collisionNormal") or {"x":0,"y":0,"z":0}
airborne_jump = vel["z"] > JUMP_VZ
horiz_normal = r.get("collisionNormalValid") and abs(n["z"]) < HORIZ_NZ
if airborne_jump and horiz_normal:
return "airborne-stuck"
return "stuck"
def main(path):
counts = {"ok":0, "partial":0, "stuck":0, "airborne-stuck":0}
total_move = 0
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
c = classify(rec)
if c is None:
continue
total_move += 1
# airborne-stuck is a subset of stuck for the bucket table but we
# report it separately AND fold it under stuck for the % columns.
if c == "airborne-stuck":
counts["airborne-stuck"] += 1
counts["stuck"] += 1
else:
counts[c] += 1
if total_move == 0:
print("no move-intent records"); return
print(f"move-intent resolves: {total_move}")
for k in ("ok", "partial", "stuck"):
print(f" {k:16s} {counts[k]:6d} {100.0*counts[k]/total_move:5.1f}%")
print(f" {'airborne-stuck':16s} {counts['airborne-stuck']:6d} (frames; subset of stuck)")
print("retail target: ok ~78% partial ~9% stuck ~13% airborne-stuck 0")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "acdream-crowd-resolve.jsonl")
```
- [ ] **Step 2: Validate against the existing baseline**
Run: `py tools/analyze_resolve_capture.py acdream-crowd-resolve.jsonl`
Expected: `move-intent resolves: ~2883`; buckets approximately `ok ~50.9% partial ~26.7% stuck ~22.4% airborne-stuck ~115` — i.e. it reproduces the design §2 acdream column. If the counts diverge materially from the design's numbers, tune `EPS_REACH` / `JUMP_VZ` until the classifier matches the design's hand-derived figures (the design numbers are the ground truth for the classifier's calibration). Record the exact calibrated numbers as the "before" baseline.
- [ ] **Step 3: Commit**
```bash
git add tools/analyze_resolve_capture.py
git commit -m "tools(#182): resolve-capture histogram classifier (A/B baseline for the physics rebuild)"
```
---
## Task 2: PhysicsBody — fsf state, CachedVelocity, and the ungated small-velocity-zero (Slice 1a)
**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs`
- Test: `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs`
- [ ] **Step 1: Write failing tests**
```csharp
[Fact]
public void TransientStateFlags_HasStationaryBits()
{
Assert.Equal(0x10u, (uint)TransientStateFlags.StationaryFall);
Assert.Equal(0x20u, (uint)TransientStateFlags.StationaryStop);
Assert.Equal(0x40u, (uint)TransientStateFlags.StationaryStuck);
}
[Fact]
public void UpdatePhysicsInternal_ZeroesSmallVelocity_EvenWhenAirborne()
{
// Retail UpdatePhysicsInternal (0x00510700) zeroes velocity below 0.25 m/s
// regardless of OnWalkable; gravity re-accelerates the same frame via v += a*dt.
var b = new PhysicsBody { State = PhysicsStateFlags.Gravity };
b.TransientState = TransientStateFlags.None; // airborne
b.set_velocity(new Vector3(0.1f, 0f, 0f)); // < 0.25 m/s, no OnWalkable
b.Acceleration = new Vector3(0, 0, PhysicsBody.Gravity);
b.UpdatePhysicsInternal(1f / 30f);
// horizontal velocity was zeroed; only gravity (v += a*dt) remains on Z
Assert.True(MathF.Abs(b.Velocity.X) < 1e-4f);
Assert.True(b.Velocity.Z < 0f);
}
```
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsBodyTests.TransientStateFlags_HasStationaryBits"`
Expected: FAIL (flags undefined / compile error).
- [ ] **Step 2: Add the flags, fields, and fix the gate**
In `PhysicsBody.cs`, extend `TransientStateFlags` (retail transient_state bits, `handle_all_collisions` pc:282743/282749/282753):
```csharp
[Flags]
public enum TransientStateFlags : uint
{
None = 0,
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
// retail frames_stationary_fall carried across frames (transition seeds fsf from
// these; handle_all_collisions re-encodes fsf into them). pc:282743/272940.
StationaryFall = 0x00000010, // bit 4 — fsf == 1
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryStuck= 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
}
```
Add fields near `Velocity` (`:183`):
```csharp
/// <summary>Retail cached_velocity (+separate from m_velocityVector): the REALIZED
/// velocity (resolved displacement / dt) written after each transition in
/// UpdateObjectInternal (0x005158cb-005158ff). Read only for reporting / dead-reckoning /
/// camera slope-align (get_velocity 0x005113c0); NEVER fed back into the integrator.</summary>
public Vector3 CachedVelocity { get; set; }
/// <summary>Retail collision_info.frames_stationary_fall carried on the body between
/// frames. Incremented in ValidateTransition when the sphere fails to advance, consumed by
/// handle_all_collisions (fsf>1 → velocity zeroed). Round-trips via the Stationary* transient
/// bits. validate_transition pc:272625-656; handle_all_collisions pc:282695.</summary>
public int FramesStationaryFall { get; set; }
```
In `UpdatePhysicsInternal` (`:503-504`), remove the OnWalkable gate (retail zeros regardless — gravity re-accelerates via the unconditional `v += a*dt`):
```csharp
// Retail UpdatePhysicsInternal 0x005107be: zero velocity below 0.25 m/s
// UNCONDITIONALLY (not gated on OnWalkable). At jump apex this zeros the
// horizontal drift; the unconditional `Velocity += Acceleration*dt` below
// immediately re-applies gravity, so the fall still accumulates.
if (velocityMag2 - SmallVelocitySquared < 0.0002f)
Velocity = Vector3.Zero;
```
Run: `dotnet test ... --filter "FullyQualifiedName~PhysicsBodyTests"`
Expected: PASS (both new tests + the existing suite).
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.Core/Physics/PhysicsBody.cs tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs
git commit -m "feat(#182): PhysicsBody fsf state + CachedVelocity; ungate small-velocity-zero (verbatim UpdatePhysicsInternal)"
```
---
## Task 3: Complete the fsf ladder in ValidateTransition (Slice 1b — the kept-internals stub, retiring TS-3's behavior gap)
**Files:**
- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` `:4575`; the `moved` signal at `:4581`)
- Test: `tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs` (create)
- [ ] **Step 1: Write failing tests for the fsf ladder in isolation**
The ladder is: given a `Transition` whose step did/did not advance, and whose target has gravity, `ValidateTransition` increments/resets `collision_info.FramesStationaryFall` and at fsf≥2 manufactures an upward contact plane. Test via a minimal `Transition` with a controllable `SpherePath` (CheckPos == or != CurPos) and `ObjectInfo` with the gravity flag.
```csharp
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
public class FramesStationaryFallTests
{
// Build a Transition whose ValidateTransition input state is OK, with CheckPos
// == CurPos (did NOT advance) so the fsf ladder increments. Helper mirrors the
// FindTransitionalPosition setup used by CellarUpTrajectoryReplayTests.
static Transition BlockedGravityTransition(int seedFsf)
{
var t = TestTransitionFactory.GravityMover(radius: 0.48f); // see Step 2 helper
t.CollisionInfo.FramesStationaryFall = seedFsf;
t.SpherePath.SetCheckPos(t.SpherePath.CurPos, t.SpherePath.CurCellId); // no advance
return t;
}
[Theory]
[InlineData(0, 1)]
[InlineData(1, 2)]
[InlineData(2, 3)]
[InlineData(3, 3)] // saturates at 3
public void ValidateTransition_Blocked_IncrementsFsf(int seed, int expected)
{
var t = BlockedGravityTransition(seed);
t.ValidateTransition(TransitionState.OK);
Assert.Equal(expected, t.CollisionInfo.FramesStationaryFall);
}
[Fact]
public void ValidateTransition_Blocked_AtFsf2_ManufacturesUpContactPlane()
{
var t = BlockedGravityTransition(seedFsf: 2); // will become 3 → manufacture
t.ValidateTransition(TransitionState.OK);
Assert.True(t.CollisionInfo.ContactPlaneValid);
Assert.True(t.CollisionInfo.ContactPlane.Normal.Z > 0.99f); // UP
Assert.True(t.CollisionInfo.CollidedWithEnvironment);
}
[Fact]
public void ValidateTransition_Advanced_ResetsFsf()
{
var t = TestTransitionFactory.GravityMover(radius: 0.48f);
t.CollisionInfo.FramesStationaryFall = 3;
// CheckPos advanced beyond CurPos → moved == true
t.SpherePath.CheckPos = t.SpherePath.CurPos with { /* origin += (1,0,0) */ };
t.ValidateTransition(TransitionState.OK);
Assert.Equal(0, t.CollisionInfo.FramesStationaryFall);
}
}
```
Run: `dotnet test ... --filter "FullyQualifiedName~FramesStationaryFallTests"`
Expected: FAIL (fsf never changes — stub).
- [ ] **Step 2: Add the `TestTransitionFactory.GravityMover` helper**
If no reusable factory exists, add a minimal one in the test project that builds a `Transition` with a `SpherePath` (one sphere radius r at a known CurPos/CurCellId), an `ObjectInfo` whose `State` includes the gravity flag and whose `object.State` sets `PhysicsStateFlags.Gravity`, matching what `FindTransitionalPosition` sets up. Mirror the setup in `CellarUpTrajectoryReplayTests.cs:1252-1297` (`SimulateTicks`). Keep it in `tests/.../Physics/TestTransitionFactory.cs`.
- [ ] **Step 3: Implement the fsf ladder in `ValidateTransition`**
At `TransitionTypes.cs:4581-4597`, replace the stub. Compute `moved` from the retail `arg3` semantics (advanced this call). Then, gated on retail's condition (`(object_info.State & VIEWER/frozen)==0 && object-has-gravity`), run the ladder. Cite pc:272625-272656.
```csharp
// retail validate_transition arg3 == "advanced this step". Our analog: CheckPos moved
// off CurPos on the OK path (pc:272608 accept-move block). Capture it BEFORE the accept
// overwrites CurPos.
bool moved = transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos;
if (transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos)
{
sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; sp.CurOrientation = sp.CheckOrientation;
for (int i = 0; i < sp.NumSphere; i++)
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos; // cache_global_curr_center
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
}
// ── frames_stationary_fall ladder (retail validate_transition pc:272625-272656) ──
// Gate: the mover is not frozen (object_info state bit 4 clear) AND the tested object
// has gravity (state 0x400). VIEWER camera sweeps + gravity-less props are exempt.
if (!ObjectInfo.HasFrozenBit && ObjectInfo.ObjectHasGravity)
{
if (moved)
{
CollisionInfo.FramesStationaryFall = 0; // advanced → reset
}
else
{
int fsf = CollisionInfo.FramesStationaryFall;
if (fsf == 0) CollisionInfo.FramesStationaryFall = 1;
else if (fsf == 1) CollisionInfo.FramesStationaryFall = 2;
else
{
CollisionInfo.FramesStationaryFall = 3; // fsf >= 2 → manufacture UP contact
var up = Vector3.UnitZ;
// plane through the sphere bottom: d = radius - (center · up) (pc:272639-272643)
var gs = sp.GlobalSphere[0];
float d = gs.Radius - Vector3.Dot(gs.Center, up);
CollisionInfo.SetContactPlane(new System.Numerics.Plane(up, d), isWater: false);
CollisionInfo.ContactPlaneCellId = sp.CheckPos.ObjCellId;
if (!ObjectInfo.HasContactBit) // not already Contact (state & 1 == 0)
{
CollisionInfo.SetCollisionNormal(up);
CollisionInfo.CollidedWithEnvironment = true;
}
}
}
}
```
Add the `ObjectInfo` accessors used above (`HasFrozenBit`, `ObjectHasGravity`, `HasContactBit`) if absent, reading the retail `object_info.state` / `object.state` bits (frozen = bit 2 of object_info.state per pc:272625 `& 4`; gravity = object state `0x400`; contact = object state `& 1`). Confirm exact acdream field names against `ObjectInfo` in `TransitionTypes.cs`; wire from `PhysicsBody.State` / `TransientState` at `get_object_info` seed time.
Run: `dotnet test ... --filter "FullyQualifiedName~FramesStationaryFallTests"` → PASS. Then full Core suite → green.
- [ ] **Step 4: Commit**
```bash
git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs tests/AcDream.Core.Tests/Physics/TestTransitionFactory.cs
git commit -m "feat(#182): un-stub frames_stationary_fall ladder in ValidateTransition (retail 0x0050aa70; addresses TS-3 behavior gap)"
```
---
## Task 4: Round-trip fsf through PhysicsEngine.ResolveWithTransition (Slice 1c)
**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolveWithTransition` seed-in `~:999`, write-out `~:1056-1106`)
- Test: `tests/AcDream.Core.Tests/Physics/ResolveFsfRoundTripTests.cs` (create)
- [ ] **Step 1: Write failing test**
```csharp
[Fact]
public void ResolveWithTransition_SeedsAndWritesBackFsf()
{
var engine = TestPhysics.EmptyEngine(); // no landblock → NO-LANDBLOCK verbatim branch
var body = new PhysicsBody { State = PhysicsStateFlags.Gravity };
body.TransientState |= TransientStateFlags.StationaryStop; // fsf seed == 2
// A blocked-in-place move against a wall so the sphere can't advance is needed to
// increment; for the seed/writeback round-trip a zero-distance resolve suffices to
// prove the seed reaches ci and ci.fsf is written back to body.
engine.ResolveWithTransition(body.Position, body.Position, body.CellPosition.ObjCellId,
0.48f, 1.835f, 0.6f, 1.5f, isOnGround: false, body: body,
moverFlags: ObjectInfoState.IsPlayer);
// seed 0x20 → fsf 2 carried in; a zero-distance no-op resolve leaves it (no advance,
// but the NO-LANDBLOCK branch may reset). Assert the field is now populated on body.
Assert.True(body.FramesStationaryFall >= 0); // tighten once the branch behavior is pinned
}
```
(Refine the assertion once the exact seed/branch interaction is observed — the load-bearing behavior is proven end-to-end by Task 7's crowd replay; this test guards the plumbing exists.)
- [ ] **Step 2: Implement seed-in + write-out**
At the top of `ResolveWithTransition` where the `Transition`/`CollisionInfo` is initialized (near where `body` seeds ContactPlane in, `~:999`), seed fsf from the body's carried transient bits (retail `transition` 0x00512dc0 pc:280940-947):
```csharp
if (body is not null)
{
// retail transition() seeds collision_info.frames_stationary_fall from the carried
// Stationary* transient bits before find_valid_position. pc:280940-280947.
if (body.TransientState.HasFlag(TransientStateFlags.StationaryStuck)) ci.FramesStationaryFall = 3;
else if (body.TransientState.HasFlag(TransientStateFlags.StationaryStop)) ci.FramesStationaryFall = 2;
else if (body.TransientState.HasFlag(TransientStateFlags.StationaryFall)) ci.FramesStationaryFall = 1;
else ci.FramesStationaryFall = 0;
}
```
At the exit write-back (near `body.SlidingNormal = …`, `~:1106`), publish fsf onto the body so the App-layer consumer can read it:
```csharp
if (body is not null)
body.FramesStationaryFall = ci.FramesStationaryFall;
```
Run: `dotnet test ... --filter "FullyQualifiedName~ResolveFsfRoundTripTests"` → PASS. Full Core suite → green (no behavior change to locomotion: fsf=0 dormant).
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/ResolveFsfRoundTripTests.cs
git commit -m "feat(#182): round-trip frames_stationary_fall through ResolveWithTransition (seed from transient bits, write back to body)"
```
---
## Task 5: Port SetPositionInternal + handle_all_collisions into a pure Core unit (Slice 2a)
**Files:**
- Create: `src/AcDream.Core/Physics/PhysicsObjUpdate.cs`
- Test: `tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs`
- [ ] **Step 1: Write failing tests for the reflect/zero decision**
```csharp
public class HandleAllCollisionsTests
{
static PhysicsBody Airborne(Vector3 v) => new PhysicsBody {
State = PhysicsStateFlags.Gravity, TransientState = TransientStateFlags.None, Velocity = v };
[Fact]
public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent()
{
var b = Airborne(new Vector3(3f, 0f, 0f)); // moving +X into a -X wall
var n = new Vector3(-1f, 0f, 0f); // outward normal
PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 0,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
// dot = 3*-1 = -3 < 0 v += -(-3*(0.05+1))*(-1,0,0) = v + (-3.15,0,0) x -0.15
Assert.True(b.Velocity.X < 0f);
}
[Fact]
public void Fsf2_ZeroesVelocity_TheBleed()
{
var b = Airborne(new Vector3(0f, 0f, 18f)); // straight-up jump
var n = new Vector3(-0.96f, -0.25f, -0.15f); // near-horizontal creature normal
PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 2,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.Equal(Vector3.Zero, b.Velocity); // fsf>1 → v=0 → gravity resumes → glide off
}
[Fact]
public void StayingOnWalkable_DoesNotReflect()
{
var b = new PhysicsBody { Velocity = new Vector3(3f,0f,0f),
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable };
var n = new Vector3(-1f,0f,0f);
PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 0,
collisionNormalValid: true, collisionNormal: n,
prevContact: true, prevOnWalkable: true, nowOnWalkable: true);
Assert.Equal(3f, b.Velocity.X); // should_reflect == false → corridor wall-slide unchanged
}
[Fact]
public void Fsf_EncodesIntoTransientBits()
{
var b = Airborne(new Vector3(0,0,1f));
PhysicsObjUpdate.HandleAllCollisions(b, framesStationaryFall: 3,
collisionNormalValid: false, collisionNormal: default,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.True(b.TransientState.HasFlag(TransientStateFlags.StationaryStuck));
}
}
```
Run → FAIL (type missing).
- [ ] **Step 2: Implement the port**
```csharp
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Verbatim port of the collision-response tail of retail CPhysicsObj::UpdateObjectInternal:
/// SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). Pure functions over
/// a PhysicsBody + the resolve outcome, so the whole chain is unit-testable in Core without the
/// App per-frame loop. The transition INTERNALS (ResolveWithTransition and below) are untouched.
/// </summary>
public static class PhysicsObjUpdate
{
/// <summary>
/// retail handle_all_collisions (0x00514780, pc:282647). Reflects or zeros the body's
/// velocity based on frames_stationary_fall, then re-encodes fsf into the Stationary*
/// transient bits. This is the velocity "bleed on block": fsf>1 → velocity = 0.
/// </summary>
public static void HandleAllCollisions(
PhysicsBody body, int framesStationaryFall,
bool collisionNormalValid, Vector3 collisionNormal,
bool prevContact, bool prevOnWalkable, bool nowOnWalkable)
{
// var_10_1 (pc:282653-282657): reflect UNLESS staying on walkable ground (and not
// sledding). Restores retail's broader rule — acdream's AD-25 airborne-only
// suppression is retired (the landing-snap fragility it guarded is gone: the landing
// state is now owned by ApplyResolvedPosition below, not a Velocity.Z<=0 gate).
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
if (framesStationaryFall <= 1)
{
if (shouldReflect && collisionNormalValid)
{
if (body.State.HasFlag(PhysicsStateFlags.Inelastic))
{
body.Velocity = Vector3.Zero; // pc:282720-282722
}
else
{
float dot = Vector3.Dot(body.Velocity, collisionNormal);
if (dot < 0f) // moving into surface
{
float k = -(dot * (body.Elasticity + 1f)); // pc:282712
body.Velocity += collisionNormal * k;
}
}
}
}
else
{
body.Velocity = Vector3.Zero; // fsf>1 → THE BLEED (pc:282729)
}
// encode fsf → transient bits (pc:282737-282758)
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= framesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
_ = prevContact; // reserved for report_environment_collision parity (weenie events, later)
}
}
```
Run → PASS. Commit.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.Core/Physics/PhysicsObjUpdate.cs tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs
git commit -m "feat(#182): port handle_all_collisions (reflect/zero by frames_stationary_fall) as a pure Core unit"
```
---
## Task 6: Wire the ported chain into PlayerMovementController, replacing the ad-hoc reflect/land block (Slice 2b — the behavioral fix)
**Files:**
- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (`:931-1109`)
- Test: `tests/AcDream.App.Tests/` (a controller-level test if the harness exists; otherwise rely on Task 7's Core replay + the visual gate)
- [ ] **Step 1: Compute CachedVelocity and route the response through PhysicsObjUpdate**
Replace the apply/reflect/land block (`:931-1109`). New sequence (retail `UpdateObjectInternal` tail + `SetPositionInternal`):
```csharp
// ── Apply the resolve as retail UpdateObjectInternal does (0x005156b0) ──
// cached_velocity = realized displacement / dt (separate from the integrator velocity;
// reporting/DR only). pc:005158cb-005158ff.
if (physicsTickRan && tickDt > 0f)
_body.CachedVelocity = (resolveResult.Position - preIntegratePos) / tickDt;
else
_body.CachedVelocity = Vector3.Zero;
_body.Position = resolveResult.Position;
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
PositionManager?.UseTime(); // retail UpdateObjectInternal tail (R5-V3, keep)
}
// SetPositionInternal (0x00515330): set contact/walkable from the resolved contact plane,
// then handle_all_collisions. The contact plane + fsf were written onto _body by
// ResolveWithTransition. prevContact/prevOnWalkable are captured BEFORE this block mutates them.
bool prevContact = _body.InContact;
bool prevOnWalkable = _body.OnWalkable;
// Contact bit from contact_plane_valid; then walkable from the plane's slope. (pc:283468-283510)
if (_body.ContactPlaneValid)
{
_body.TransientState |= TransientStateFlags.Contact;
bool walkable = _body.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ; // set_on_walkable
if (walkable) _body.TransientState |= TransientStateFlags.OnWalkable;
else _body.TransientState &= ~TransientStateFlags.OnWalkable;
}
else
{
bool wasOnWalkable = _body.OnWalkable;
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
if (wasOnWalkable) _motion.LeaveGround(); // retail movement_manager->LeaveGround (pc:283494)
}
_body.calc_acceleration();
bool nowOnWalkable = _body.OnWalkable;
PhysicsObjUpdate.HandleAllCollisions(
_body, _body.FramesStationaryFall,
resolveResult.CollisionNormalValid, resolveResult.CollisionNormal,
prevContact, prevOnWalkable, nowOnWalkable);
// Motion-manager notifications on the grounded/airborne EDGES (acdream keeps these — they
// are the CMotionInterp HitGround/LeaveGround hooks the retail chain drives elsewhere).
bool justLanded = false;
if (nowOnWalkable && !_wasAirborneLastFrame == false /* was airborne */ )
{
// wasAirborne && nowOnWalkable → land
}
if (nowOnWalkable && _wasAirborneLastFrame)
{
Movement.HitGround();
justLanded = true;
}
if (!nowOnWalkable && !_wasAirborneLastFrame)
_motion.LeaveGround();
_wasAirborneLastFrame = !nowOnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");
```
> **Integration note (verify while writing):** the old block used `resolveResult.IsOnGround` + a `Velocity.Z <= 0` gate to decide landing and to zero `Velocity.Z`. The rebuild derives grounded state from `_body.ContactPlaneValid`/`OnWalkable` (set by `ResolveWithTransition`'s writeback), not `IsOnGround`, and no longer zeros `Velocity.Z` by hand — `handle_all_collisions` (fsf) + `calc_acceleration` (grounded → accel 0) own the settle. Keep `justLanded` feeding whatever downstream (`§6 outbound`, animation) consumed it. Preserve the HitGround-on-land / LeaveGround-on-depart edges exactly; only their *trigger* changes from `IsOnGround && v.z<=0` to `OnWalkable` transitions.
- [ ] **Step 2: Build + full suites**
Run: `dotnet build` (green), then `dotnet test` (Core + App + UI + Net). Expected: green — ordinary locomotion is fsf=0 dormant; the changed landing trigger must not regress the walk/slope/stairs/jump/fall conformance tests. If any locomotion test regresses, bisect within this task (the landing-trigger change is the prime suspect).
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Input/PlayerMovementController.cs
git commit -m "feat(#182): route player collision response through ported SetPositionInternal+handle_all_collisions (retires the ad-hoc reflect/land block)"
```
---
## Task 7: Crowd-jump A/B replay conformance test (Slice 2c — measured proof)
**Files:**
- Test: `tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpReplayTests.cs` (create)
- Fixture: a trimmed slice of `acdream-crowd-resolve.jsonl` covering the airborne-stuck frames → `tests/AcDream.Core.Tests/Fixtures/issue182/crowd-airborne-stuck.jsonl`
- [ ] **Step 1: Write the replay test (pattern from CellarUpTrajectoryReplayTests)**
Seed a `PhysicsBody` from a captured `bodyBefore` at the first airborne-stuck frame (`velocity ≈ (0,0,18)`, near-horizontal collision normal), then drive the ported per-frame chain (Resolve → PhysicsObjUpdate) for N frames and assert the body **leaves the stuck state**: `FramesStationaryFall` climbs past 1 → `Velocity` collapses to ~0 → subsequent frames show downward Z (gravity/glide), not a persisted +Z. Use `SeedBodyFromSnapshot` + `LoadCapturedRecord` from the existing harness.
```csharp
[Fact]
public void BlockedAirborneJump_BleedsVelocity_WithinThreeFrames()
{
var rec = LoadCapturedRecord(r => r.BodyBefore is { } b && b.Velocity.Z > 15f
&& r.Result.CollisionNormalValid && MathF.Abs(r.Result.CollisionNormal.Z) < 0.5f);
var body = SeedBodyFromSnapshot(rec.BodyBefore);
// simulate the blocked frames against the same captured target — the sphere cannot
// advance, so fsf climbs 0→1→2 and handle_all_collisions zeros velocity at fsf>1.
for (int frame = 0; frame < 3; frame++)
SimulateBlockedFrame(body, rec.Input); // Resolve + PhysicsObjUpdate, target == captured target
Assert.True(body.Velocity.Length() < 1.0f, $"velocity did not bleed: {body.Velocity}");
Assert.True(body.FramesStationaryFall >= 2);
}
```
Run → PASS.
- [ ] **Step 2: Whole-capture A/B measurement (manual gate, not a unit test)**
Rebuild the client, reproduce the crowd-jump repro live with `ACDREAM_CAPTURE_RESOLVE=after-crowd.jsonl`, then:
```
py tools/analyze_resolve_capture.py after-crowd.jsonl
```
**Gate:** `airborne-stuck → 0`, and `ok%` climbs toward retail's ~78% (from 50.9%). Record before/after in the commit message / ISSUES #182. If `ok% << 78%` with airborne-stuck at 0, that is the Slice 3 residual (§7 Q3) — proceed to measure TS-4.
- [ ] **Step 3: Commit**
```bash
git add tests/AcDream.Core.Tests/Physics/Issue182CrowdJumpReplayTests.cs tests/AcDream.Core.Tests/Fixtures/issue182/
git commit -m "test(#182): crowd-jump airborne-stuck replay — blocked jump bleeds velocity within 3 frames"
```
---
## Task 8: Register bookkeeping + docs (same commit boundary as Task 6/7)
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md`
- Modify: `docs/ISSUES.md`, `claude-memory/project_physics_collision_digest.md`
- [ ] **Step 1: Retire / amend rows**
- **Retire TS-3** (`:195`) — the fsf accounting is now ported (delete the row; note the retirement in the TS-section header line like the TS-45 note).
- **Amend AD-25** (`:88`) — the player-half reflect suppression is gone (retail's broader rule restored via `handle_all_collisions`). AD-25 **still covers the remote-DR sweep** in `GameWindow.cs` (#173), which is out of this arc's scope. Rewrite AD-25 to reference only the remote-DR site + note the player half was retired by this rebuild; OR split into a new AD row for the remote half. Do NOT delete (the remote sweep still suppresses).
- **Add** any adaptation this port introduces: e.g. `CachedVelocity` is computed but not yet consumed by the wire (reporting still uses the old path) — a small AP row if the wire velocity differs from retail's cached_velocity source; the fsf gate's `HasFrozenBit`/`ObjectHasGravity` derivation if it approximates retail's exact bits.
- [ ] **Step 2: Update ISSUES + digest**
- `docs/ISSUES.md`: move #182 forward (airborne-stuck fixed; note the A/B numbers; residual ground-jam tracked if <78%).
- `claude-memory/project_physics_collision_digest.md`: replace the 2026-07-07 top banner's "designed (deferred)" with "SHIPPED" + the fsf mechanism + the A/B result + a DO-NOT-RETRY note ("the bleed is fsf>1→v=0 in handle_all_collisions, NOT cached_velocity; calc_friction 0.0 vs retail 0.25 is a SEPARATE L.3c divergence, do not fold it in").
- [ ] **Step 3: Commit** (fold into Task 6 or 7's commit if landing together, or a trailing docs commit).
---
## Task 9: STOP for the user visual gate
Do **not** proceed to Slice 3 without the user's visual verification. Present:
1. **Crowd glide/land** (the #182 symptom): jump into a monster crowd — does the player land/glide across the tops and shuffle out, like retail?
2. **Normal-locomotion regression pass:** flat walking, slopes, stairs, jumping, falling, wall-slide — unchanged?
Only the user can confirm these. The suites + A/B histogram are necessary but not sufficient.
---
## Task 10 (conditional): Slice 3 — residual ground-jam
Only if Task 7's A/B shows `ok% << 78%` with airborne-stuck at 0. Measure which bucket dominates (partial vs stuck) and correlate against `slidingNormal` provenance. Prime suspect: **TS-4** (BSPQuery Path-6 steep persisted sliding-normal → #137 anti-parallel-absorb). That is in the *kept* internals — a separate, decomp-anchored fix (brainstorm a sub-slice with the user before touching it, per the roadmap-divergence rule). Do not pre-emptively touch it.
---
## Self-Review
**Spec coverage (design §4.1 functions):**
- `UpdateObjectInternal` chain → Task 6 (consolidated in the controller) + the retail-pseudocode appendix cited in code.
- `UpdatePositionInternal`/`calc_acceleration` → already present in `PhysicsBody`; the OnWalkable-gate divergence fixed in Task 2. `calc_friction` explicitly out of scope (documented, orthogonal L.3c divergence).
- `handle_all_collisions` → Task 5 (pure unit) + Task 6 (wired). **Design §7 Q1 answered: retail reflects (fsf≤1) then zeros (fsf>1).**
- `SetPositionInternal` (0x00515330 confirmed, §7 Q2) → Task 6.
- `transition`/`ResolveWithTransition` reused as-is; fsf seed/writeback added (Task 4) — the only touch to the kept-internals seam, justified as completing the TS-3 stub.
- The velocity-from-movement recompute (§4.2) → `CachedVelocity` (Task 6), correctly kept separate from the integrator per the verified two-velocity model.
**Staging (design §4.5):** Slice 0 (measurement) → Slice 1 (fsf substrate: Tasks 2-4, dormant in locomotion) → Slice 2 (the fix: Tasks 5-7, A/B gate) → visual gate → Slice 3 (conditional residual). The design's "airborne-stuck→0 after Slice 1" gate correctly moves to Slice 2 (Task 7) — a direct consequence of the corrected mechanism.
**Type consistency:** `FramesStationaryFall` (int) and `CachedVelocity` (Vector3) on `PhysicsBody`; `TransientStateFlags.Stationary{Fall,Stop,Stuck}` = 0x10/0x20/0x40; `PhysicsObjUpdate.HandleAllCollisions(body, fsf, cnValid, cn, prevContact, prevOnWalkable, nowOnWalkable)` — signature used identically in Tasks 5 & 6.
**Placeholder scan:** two spots are marked "verify while writing" (the `ObjectInfo` frozen/gravity/contact bit accessors in Task 3; the landing-edge wiring in Task 6) — these are genuinely dependent on exact acdream `ObjectInfo`/controller field names to be read at implementation time, not hand-wavy gaps; the retail semantics + pc anchors are pinned. All code steps show concrete code.
**Risk (design §6):** replaces the core of every jump/fall/step. Mitigations honored: transition internals untouched (except the TS-3 stub completion), fsf dormant in ordinary locomotion, measured A/B before the visual gate, and a hard stop for visual verification.

View file

@ -0,0 +1,223 @@
# #185 Outdoor-Stairs Phantom Fix — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: this plan is intended for **inline execution by the
> lead engineer with full context** (superpowers:executing-plans), NOT subagent dispatch — it edits
> frozen collision internals and pins the fix empirically (CLAUDE.md: "Do not integrate via subagent
> unless the subagent has the full context"). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** the local player walks up the full house-on-stilts outdoor staircase with no jam and no jump.
**Architecture:** restore retail's invariant that a grounded mover on a continuous walkable surface
keeps `contact_plane_valid` on the forward move (`transitional_insert` short-circuit, pc 273244), so
it never falls into the step-down recovery that cannot reach the *coplanar (at-level)* stair
continuation and fabricates a horizontal `(0,1,0)` sliding normal (`PrecipiceSlide`, the #137/TS-4
family). The exact retention-loss line is pinned by a dat-backed replay test, then fixed faithfully.
**Tech stack:** C# .NET 10, xUnit, `DatCollection` dat reader, `AcDream.Core.Physics`
(`PhysicsEngine.ResolveWithTransition`, `Transition`, `SpherePath`, `ShadowObjectRegistry`).
**Design spec:** `docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md` (read first).
**Fixtures already captured (this session, in the scratchpad):**
`185-recapture.jsonl` (jam pinned at `(131.71, 77.914, 61.485)`, `slidingNormal (0,1,0)`,
`collisionNormal (0,0.78,0.62)`); `gfxdump/0x01000AC5.gfxobj.json` (step-box tread = poly id 4, local
plane `(-0.625,0,0.781,-0.312)`); `[resolve-bldg]` per-object world origins, e.g. `0xF6822103 →
entOrigin_lb (132.0,77.2,60.4)`, tread world verts `(132.75,77.495,61.015)…`. **Copy the two gfxobj
dumps into `tests/AcDream.Core.Tests/Physics/fixtures/` in Task 1** so the test is self-contained.
**Reference tests to mirror (read before writing Task 1):**
`tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs` (replay + probe-capture shape),
`DoorBugTrajectoryReplayTests.cs` / `CellarUpTrajectoryReplayTests.cs` (registering a **building/shadow
object** and replaying a trajectory — the outdoor-static harness #185 needs), `GfxObjDumpRoundTripTests.cs`
(loading a gfxobj + its physics polys), `ShadowObjectRegistryMultiPartTests.cs` (`RegisterMultiPart` API).
---
## Task 1: Red replay test — reproduce the wedge over the real stair objects
**Files:**
- Create: `tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs`
- Create (fixtures): copy `…/scratchpad/gfxdump/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) →
`tests/AcDream.Core.Tests/Physics/fixtures/`
- Read (harness API): `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:92,152`
(`Register` / `RegisterMultiPart`), `PhysicsEngine.ResolveWithTransition`, and the two building-replay
reference tests above.
- [ ] **Step 1: Read the reference harness.** Read `DoorBugTrajectoryReplayTests.cs` and
`CellarUpTrajectoryReplayTests.cs` end-to-end to learn exactly how they (a) build a `PhysicsEngine`,
(b) register a building object's collision shape (`ShadowShapeBuilder`/`RegisterMultiPart` from a
Setup/GfxObj + world transform), and (c) drive `ResolveWithTransition`. Read
`GfxObjDumpRoundTripTests.cs` for how a gfxobj's `ResolvedPolygons` (the physics polys, incl. tread
poly id 4) are obtained from the dat. Confirm whether registration takes a Setup id or a GfxObj id;
the stairs are landblock statics whose collision is the GfxObj's own BSP/polys (no Setup cylsphere).
- [ ] **Step 2: Write the harness + the FAILING wedge test.** Build a `PhysicsEngine`, register the
stair step objects at their captured world transforms (each = `entOrigin_lb` translation × the +90°-about-Z
rotation that maps local tread normal `(-0.625,0,0.781)` → world `(0,-0.625,0.781)`), covering at least
the objects spanning the jam seam (`0xF68221` parts 13 at entOrigin Y 76.2/76.7/77.2 **and** the next
object up whose tread covers Y∈[77.5,78.0]+ — derive its origin as the +0.5 world-Y / +0.4 world-Z step
from `0xF68221` part 3). Seed a grounded body on tread N (contact plane + walkable poly = the tread quad,
world verts `(132.75,77.495,61.015),(131.25,77.495,61.015),(131.25,76.995,60.615),(132.75,76.995,60.615)`).
Replay the forward run from `(131.72,77.49,61.15)` toward `(131.71,78.18,61.15)` for ~6 held-forward
frames (flat target, +Y ≈ 0.2/frame — the movement controller sends flat, physics climbs). Use the
`ResolveWithTransition` args from the capture: `sphereRadius 0.48`, `sphereHeight 1.835`,
`stepUpHeight 0.6`, `stepDownHeight 1.5`, `isOnGround true`, `moverFlags IsPlayer|EdgeSlide`.
```csharp
// The wedge assertion (RED before the fix): the run must climb PAST the seam, not pin at Y≈77.9.
Assert.True(pos.Y > 78.10f,
$"Player must climb past the tread seam (reached Y={pos.Y:F3}); pinned at ~77.9 with " +
$"slidingNormal=(0,1,0) = the #185 fabricated-precipice wedge.");
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family).");
```
- [ ] **Step 3: Run it — verify it FAILS (reproduces the wedge).**
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~Issue185OutdoorStairsSeamReplay" -v n`
Expected: **FAIL**`pos.Y` pins at ~77.9 and/or `Sliding` flag set (the wedge reproduced offline).
**If it does NOT reproduce** (climbs clean offline), the live trigger involves state beyond the replay's
reach (as the #137 corridor replay initially did) — STOP and add the missing seed (the exact
`bodyBefore` from `185-recapture.jsonl` tick 4277: contact plane, walkable verts, and the prior-frame
`slidingNormal`) before proceeding; do not weaken the assertion.
- [ ] **Step 4: Commit the red test.**
```bash
git add tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs \
tests/AcDream.Core.Tests/Physics/fixtures/0x01000AC5.gfxobj.json \
tests/AcDream.Core.Tests/Physics/fixtures/0x01000ACA.gfxobj.json
git commit -m "test(#185): red — dat-backed outdoor-stairs seam replay reproduces the wedge
Registers the real stair step-boxes and replays the captured forward run; pins the
jam (no advance past Y~77.9, persisted (0,1,0) sliding normal). RED before the fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Pin the exact retention-loss site
**Files:**
- Modify (temporary probe only, reverted at end): the replay test — wrap the seam-crossing frame with
`PhysicsDiagnostics.ProbeContactPlaneEnabled = true` (env `ACDREAM_PROBE_CONTACT_PLANE`) +
`ProbeStepWalkEnabled` + `ProbeIndoorBspEnabled`, capturing `Console.Out` (exactly as
`Issue137CorridorSeamReplayTests.SeamShake_WestBoundary_SnapshotExact_Advances` does, lines 207236).
- Read: `src/AcDream.Core/Physics/TransitionTypes.cs` (TransitionalInsert loop `~10301110`
Slid-clears at 1047/1073/1102; the neg-poly/step-down dispatch `~1339`; `ValidateTransition` LKCP
retention `~46454735`), `src/AcDream.Core/Physics/PhysicsEngine.cs` (seed `~1004`, writeback `~1131`).
- [ ] **Step 1: Capture the seam-crossing frame's contact-plane transitions.** Run the red test with the
probes on; in the emitted trace, find the frame that reaches the step-down dispatch with
`ci.ContactPlaneValid == false`, and identify **which** upstream event set it false:
(a) an env/building/object channel returned **Slid** (clears cp at 1047/1073/1102), or
(b) the seed at `PhysicsEngine.cs:1004` never re-validated and the forward main-sweep set no fresh
contact plane, or
(c) `ValidateTransition`'s LKCP proximity guard (`~4698`, `radius+ε > |angle|`) DROPPED the plane
because the floated sphere is >radius from tread N's plane.
Record the concrete site (file:line + the branch) in a scratch note; this selects the Task-3 candidate.
- [ ] **Step 2: Confirm the retail-faithful expectation for that site.** Grep the named-retail decomp for
the corresponding retail function (the Slid channel → `BSPTREE::find_collisions` Contact branch
pc 323838+, or `set_contact_plane`/`get_object_info` pc 272547 for the retain path) and confirm what
retail does that keeps `contact_plane_valid` here. Do NOT invent behavior — cite the pc line. This is
the gate: the fix must make acdream match that retail behavior, not add a guard.
---
## Task 3: Apply the retail-faithful grounding-retention fix
**Files:**
- Modify: the pinned site from Task 2 in `src/AcDream.Core/Physics/TransitionTypes.cs` (or
`PhysicsEngine.cs`). Exactly ONE of the candidates below, chosen by Task 2's pin + Task 2's retail cite.
- [ ] **Step 1: Apply the fix matching the pinned site.** Candidate edits (pick the one Task 2 selected):
- **Candidate A (pin = spurious Slid on the coplanar-graze):** the grounded foot grazing the coplanar
continuation tread is being routed to a Slid (clearing cp) where retail records a walkable
contact/step (keeps cp). Fix the specific collision-response branch so a grounded mover grazing a
**walkable** (normal.Z ≥ FloorZ) coplanar poly it is resting on retains/sets the contact plane
instead of clearing it — mirroring the retail Contact-branch path Task 2 cited. Do not blanket-skip
Slid; scope to the walkable-coplanar-continuation case.
- **Candidate B (pin = forward move sets no fresh contact plane):** ensure the grounded forward move
re-establishes `ci.ContactPlane` from the coplanar tread the sphere rests on (retail's forward
landing keeps `contact_plane_valid`, pc 273244) — e.g. the walkable-landing/`find_walkable` on the
forward insert must find the at-level continuation, not only a below-foot surface.
- **Candidate C (pin = LKCP proximity guard drops the plane):** the floated sphere separates from
tread N's plane by >radius so the guard (`~4698`) drops it — but the sphere is genuinely resting on
the coplanar continuation. Fix so the proximity check is evaluated against the surface actually under
the sphere (the coplanar continuation), matching retail's `get_object_info` retain logic (pc 272547),
rather than dropping grounding at the seam.
Each candidate is a **localized** change. If the pinned faithful fix is larger than a localized edit,
STOP and return to the user (scope guard, per the spec §3).
- [ ] **Step 2: Register bookkeeping in the same commit.** Amend `docs/architecture/retail-divergence-register.md`
row **TS-4** (or add a new row) to record the grounding-retention change and its retail oracle
(pc 273244 / the Task-2 cite); retire/narrow as the port dictates.
---
## Task 4: Green the replay + remove the temporary probes
- [ ] **Step 1: Remove the temporary probe wrapping** added in Task 2 (leave the test asserting behavior,
no probe side effects).
- [ ] **Step 2: Run the red test — verify it now PASSES.**
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~Issue185OutdoorStairsSeamReplay" -v n`
Expected: **PASS** — the run climbs past Y=78.10 with no persisted sliding normal.
- [ ] **Step 3: Commit the fix + green test.**
```bash
git add -A
git commit -m "fix(#185): keep the mover grounded on a continuous walkable ramp seam
<one line naming the pinned site + the retail oracle from Task 2>. The grounded
forward move now retains contact_plane_valid across the coplanar stair seam
(retail transitional_insert pc 273244) instead of dropping into the step-down
recovery that can't reach the at-level continuation and fabricates a horizontal
(0,1,0) sliding normal (PrecipiceSlide, #137/TS-4 family). Register TS-4 amended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: Regression — the shared grounded-move path
- [ ] **Step 1: Run the #137 suites (must stay green / un-skipped).**
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~Issue137" -v n`
Expected: PASS (`Issue137CorridorSeamReplayTests`, `Issue137SlidingNormalLifecycleTests`,
`Issue137CorridorSeamInspectionTests`). Also run `CellarUpTrajectoryReplayTests`, `BSPStepUpTests`,
`SphereCollisionFamilyTests` — the step-up/step-down/slide neighbors.
- [ ] **Step 2: Full build + test.**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then
`dotnet test` (Core / App / UI / Net). Expected: all green, 0 warnings.
- [ ] **Step 3: If any regressed,** the fix over-reached (not scoped to the walkable-coplanar-continuation
case) — narrow it and re-run; do NOT loosen the regression assertions.
---
## Task 6: Bookkeeping (docs + memory)
- [ ] **Step 1:** `docs/ISSUES.md` — move **#185** to Recently closed with the fix SHA.
- [ ] **Step 2:** `claude-memory/project_physics_collision_digest.md` — add the #185 banner (root cause +
the fix + DO-NOT-RETRY: don't un-zero Z, don't touch step-up budget, fix the grounding provenance).
- [ ] **Step 3:** roadmap/milestones — note the M1.5 #137-family stair fix if a phase line applies.
- [ ] **Step 4: Commit the bookkeeping.**
```bash
git add docs/ISSUES.md claude-memory/project_physics_collision_digest.md
git commit -m "docs(#185): close — grounding-retention fix for the outdoor-stairs phantom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: Live visual gate (USER — acceptance)
- [ ] **Step 1: Build green, then launch** (canonical live command; no pre-close of running clients).
- [ ] **Step 2: USER walks `+Acdream` up the FULL outdoor staircase** — must climb with no jam and no
jump. Then regression spot-checks: indoor cellar/corridor stairs, another outdoor building, a ramp,
flat ground — no new phantom blocks AND no sliding through walkable ramps/edges.
- [ ] **Step 3:** on pass, #185 is closed (bookkeeping already landed in Task 6). On fail, capture with
the apparatus (§6 of the spec) and re-pin — do not guess a follow-up.

View file

@ -0,0 +1,292 @@
# Movement / Animation Wire-Parity Slice — Design
Date: 2026-06-30
Phase: **L.2b** (movement wire/contact authority) + **L.1b** (command router + motion-state cleanup)
Standard: **decomp-verbatim, tests-first. No approximations, no "feels like retail."**
## Source documents (read first)
- `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the 12
confirmed divergences (D1D12) and the Phase 05 plan.
- `docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md` — the ACE-vs-2013
command-catalog divergence (130 common names with different values; the `+3`
low-word shift beginning at `SnowAngelState`).
- Oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build) + `docs/research/named-retail/acclient.h`. Live confirmation via the
Ghidra HTTP bridge at `http://127.0.0.1:8081` (decompile-by-address).
- Plans of record this slice serves: `docs/plans/animation-system-audit.md` (L.1)
and `docs/plans/2026-04-29-movement-collision-conformance.md` (L.2).
## Goal
Make the **outbound movement wire bytes** (`MoveToState 0xF61C`, `Jump 0xF61B`,
`AutonomousPosition 0xF753`) and the **inbound→animation command catalog**
byte-for-byte / value-for-value faithful to retail, proven by tests whose golden
values are derived directly from the 2013 decomp packers and cross-checked
against holtburger.
This slice fixes the *packing* and the *catalog*. It does **not** port the
motion-interpreter input-state construction (`adjust_motion` /
`apply_raw_movement`) — that is the next slice (D6 / audit Phase 2), explicitly
deferred below.
## Scope
### In scope
- **D1**`RawMotionState::Pack` default-difference flag packing (replaces
presence-based flags).
- **D3**`MoveToStatePack` trailing byte = `contact | standingLongjump`.
- **D4**`JumpPack` layout (extent, velocity, full Position, four timestamps;
drop the spurious objectGuid/spellId).
- **Catalog gap** — dual command catalog (`AceModernCommandCatalog` runtime
default + `Retail2013CommandCatalog` conformance), built behind an
`IMotionCommandCatalog` seam; full 2013 `command_ids[0x198]` extraction;
DAT-availability tests.
- **Phase 0 oracle tests** — written first and failing, encoding the four retail
`Pack` functions and the catalog matrices.
### Out of scope (later L.1/L.2 slices — see "Deferred / unresolved")
D2 (runtime actions-list/current_style emission), D5 (heartbeat-clock stamping —
*audit only* this slice), D6 (`adjust_motion`/`apply_raw_movement` input-state),
D7 (animation/motion application reorder), D8 (MoveToManager/force-walk), D9
(inbound types 8/9), D10 (spawn-time MoveTo), D11 (sequence/autonomy
propagation), D12 (jump/contact gates).
## Decisions (locked with the user 2026-06-30)
1. **First slice = catalog + outbound wire** (not catalog-only, not + motion core).
2. **Golden-byte oracle = decomp-derived + holtburger cross-check**, confirmed via
the Ghidra bridge. No live retail capture for this slice (captures are reserved
for Phase 2, where retail's *input state* is the genuine unknown).
3. **Catalog mechanism = Option A**`IMotionCommandCatalog` interface; static
`MotionCommandResolver.ReconstructFullCommand(ushort)` is retained, delegating
to an `AceModernCommandCatalog` singleton so all ~10 runtime call sites are
unchanged. Tests instantiate `Retail2013CommandCatalog` explicitly.
4. **2013 table depth = full extraction** (~408 `command_ids[0x198]` entries with
provenance), not a test-matrix stub.
## Component design
### 1. Command catalog (L.1b)
**Seam.** New interface in `AcDream.Core.Physics`:
```
public interface IMotionCommandCatalog
{
/// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value.
/// Returns 0 if no entry matches.
uint ReconstructFullCommand(ushort wireCommand);
}
```
**`AceModernCommandCatalog : IMotionCommandCatalog`** — the runtime default.
Built from the DatReaderWriter / ACE `MotionCommand` enum (these share values;
ACE's enum is the authoritative ACE-modern catalog and matches the local DATs).
Reconstruction = wire-low → full lookup, with collisions resolved by the
documented retail class priority (lower class byte wins: Action 0x10 < SubState
0x41 < Style 0x80 …) applied **at build time**, producing a flat
`IReadOnlyDictionary<ushort,uint>`.
- **Verify and (likely) delete the magic override.** The current
`MotionCommandResolver.ApplyNamedRetailOverrides` force-maps `0x016E0x0197`
to `0x10000000 | lo`. Once `AceModernCommandCatalog` is built cleanly from the
ACE enum, assert the ACE test matrix passes (`0x0153→0x10000153`,
`0x0166→0x10000166`, `0x0171→0x10000171`, `0x0173→0x10000173`). If it passes
**without** the override loop, delete the loop (unexplained magic range; see
the magic-number-audit memory). If a specific command still resolves to the
wrong class, add a **per-command, cited** override — never a blind range.
- **Fix the wrong comment.** The shift starts at `SnowAngelState` (`0x43000115`
`0x43000118`), not `AllegianceHometownRecall`.
**`Retail2013CommandCatalog : IMotionCommandCatalog`** — conformance/reference.
Built from the **full** 2013 `command_ids[0x198]` table at `0x007c73e8` paired
with the command-name table at `0x008041ec..0x0080444c`. Deterministic
extraction from `acclient_2013_pseudo_c.txt` (and/or the Ghidra bridge), emitted
as a generated/static name→value table in the source with a provenance header
citing both addresses. ~408 entries.
**`MotionCommandResolver`** keeps `public static uint ReconstructFullCommand(ushort)`
delegating to a private `static readonly AceModernCommandCatalog s_aceModern`.
All current callers (`AnimationCommandRouter`, `CombatAnimationPlanner`, 8×
`GameWindow`) are untouched.
**DAT-availability tests.** A command animates only if the entity's `MotionTable`
has a `Links` or `Modifiers` entry for the full 32-bit value. Tests
(`MotionCommandCatalogTests`, real-DAT-backed via `Chorizite.DatReaderWriter`):
- ACE mode: `0x0153→0x10000153 LifestoneRecall`, `0x0166→0x10000166`,
`0x0171→0x10000171`, `0x0173→0x10000173`.
- 2013 mode: `0x0150→0x10000150 LifestoneRecall`, `0x0163→0x10000163`,
`0x016E→0x1000016E`, `0x0170→0x10000170`.
- Availability: `LifestoneRecall (ACE 0x10000153)` and `HouseRecall` exist in
local DAT MotionTables; `MarketplaceRecall / AllegianceHometownRecall /
PKArenaRecall / OffhandSlashHigh` exist **only** under the ACE-shifted IDs
(reproduces the gap doc's link-hit scan as a fixture assertion).
### 2. RawMotionState model + default-difference packing (D1)
**Data type** `AcDream.Core.Physics.RawMotionState` — a value type mirroring the
retail struct (`acclient.h RawMotionState::PackBitfield`, decomp `0x0051ed10`):
| field | retail default | bit |
|---|---|---:|
| current_holdkey | `HoldKey.None` (1) | 0x001 |
| current_style | `0x8000003D` | 0x002 |
| forward_command | `0x41000003` | 0x004 |
| forward_holdkey | `HoldKey.Invalid` (0) | 0x008 |
| forward_speed | `1.0f` | 0x010 |
| sidestep_command | `0` | 0x020 |
| sidestep_holdkey | `HoldKey.Invalid` (0) | 0x040 |
| sidestep_speed | `1.0f` | 0x080 |
| turn_command | `0` | 0x100 |
| turn_holdkey | `HoldKey.Invalid` (0) | 0x200 |
| turn_speed | `1.0f` | 0x400 |
| actions (list) | empty | `num_actions<<11` (bits 1115) |
Pure data, no GL/Net dependency. Lives in Core.Physics so the Phase-2 motion
interpreter (also Core.Physics) can populate it without a Core.Net dependency.
A `RawMotionState.Default` exposes the retail defaults.
**Packer** in `AcDream.Core.Net` (Core.Net may reference Core.Physics; the
reverse is forbidden by Code Structure Rule #2). Ports `RawMotionState::Pack`
verbatim:
1. Build flags dword: set bit *only* when the field **differs from its default**
(`forward_speed != 1.0f`, `current_holdkey != None`, etc.), OR in
`num_actions << 11`. **Note the bitfield is bits 015 only** — bits 1631 are
unused (retail `num_actions` is 5 bits, max 31). The current
`MoveToState.cs` comment "bits 1131 = command list length" is wrong; correct
it to bits 1115 = `num_actions`.
2. Write the flags dword.
3. Write each set field in bit order (holdkey, style, fwd_cmd, fwd_holdkey,
fwd_speed, ss_cmd, ss_holdkey, ss_speed, turn_cmd, turn_holdkey, turn_speed).
4. Write each action: `u16 command` then
`u16 (stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)` (decomp lines 293945293960).
`MoveToState.Build` is refactored to take a `RawMotionState` (plus position,
sequences, contact, standingLongjump) instead of the flat nullable param list.
**Behavioral effect vs ACE:** the only runtime change is *removing* over-sent
defaults (`forwardSpeed=1.0`, `currentHoldKey=None`, default per-axis hold keys).
ACE's `UnPackNet` defaults omitted fields, so this is strictly more correct. The
slice does **not** change which RawMotionState values the caller constructs
(that is D6) — it threads the same values the caller passes today through the new
struct. Smoke-test against ACE confirms no regression.
### 3. MoveToState trailing byte (D3)
From `MoveToStatePack::Pack` (`0x005168f0`, lines 284717284722): the trailing
byte is `((longjump_mode != 0 ? 0x02 : 0) | (contact != 0 ? 0x01 : 0))`. Then
ALIGN to 4.
`MoveToState.Build` gains explicit `bool contact` and `bool standingLongjump`
params. `standingLongjump` is wired to its true current value (`false` — standing
longjump is not implemented yet). The *packing* is faithful; the *input* is
honest. When standing longjump lands (later), the param flips. This is not a
masking shortcut — it is the correct byte with the correct current input.
### 4. JumpPack (D4)
From `JumpPack::Pack` (`0x00516d10`, lines 284934284963), field order:
1. `f32 extent`
2. `f32 velocity.x, velocity.y, velocity.z`
3. `Position.Pack` (objcell_id + frame)
4. `u16 instance_timestamp, server_control_timestamp, teleport_timestamp, force_position_ts`
5. ALIGN to 4
`JumpAction.Build` is rewritten to this layout. The current spurious
`u32 objectGuid` + `u32 spellId` are **removed** (no retail evidence). New params:
`cellId`, `position`, `rotation`. Caller at `GameWindow.cs:8343` supplies them
(it already has `wireCellId` / `wirePos` / `wireRot` in scope).
### 5. AutonomousPosition (D5 audit + timestamp order)
`AutonomousPositionPack::Pack` (`0x00516af0`): Position, then `u16` instance /
server_control / teleport / force_position, then a **contact-only** byte
(`contact != 0`; no longjump bit), then ALIGN. Current code matches. Add:
- a timestamp-**order** golden test (instance, serverControl, teleport,
forcePosition);
- a contact-byte test.
**D5 audit (no behavior change unless decomp proves divergence):** read
`CommandInterpreter::SendMovementEvent` (`0x006b4680`) and `SendPositionEvent`
(`0x006b4770`) and compare what they stamp (`last_sent_position_time` vs
position vs contact_plane) to acdream's `NotePositionSent`. Document the finding;
change behavior only if the decomp shows a real divergence.
### 6. Position::Pack verification
`Position::Pack` is shared by all three messages and currently writes
`cellId, x, y, z, qw, qx, qy, qz`. It works against ACE, but for strict parity:
decompile `Position::Pack` / `Frame::Pack` by address, confirm the field order,
and lock it with a golden test on the Position block. Fix only if the decomp
diverges; otherwise the test pins it.
## Test inventory (written first, failing)
Core.Net.Tests:
- `RawMotionStatePackTests` — default state (only the flags dword, value `0`);
walk-forward speed 1.0 (forward_command + forward_holdkey set, **forward_speed
omitted**); run forward (holdkey + fwd_cmd + fwd_holdkey + fwd_speed); backward;
sidestep right/left; turn right/left; non-default current_style; a populated
actions list (num_actions bits + per-action u16 pair); contact/longjump
trailing byte combinations.
- `MoveToStateGoldenTests` — full-message golden bytes for walk, run, slow-walk
(toggle-run), sidestep, turn, contact, standing-longjump.
- `JumpActionTests` — retail `JumpPack` layout, full Position present, no
object/spell fields.
- `AutonomousPositionTests` — timestamp order + contact byte.
- `PositionPackTests` — Position block byte order.
Core.Tests:
- `MotionCommandCatalogTests` — ACE-mode + 2013-mode matrices; class-priority
collisions; the "override deleted" assertion (or per-command cited override).
- `MotionCommandCatalogDatTests` — real-DAT availability assertions.
Golden bytes are computed by hand from the decomp `Pack` functions (cited by
address in each test) and cross-checked against holtburger's Rust packers.
## Layering & placement
- `IMotionCommandCatalog`, `AceModernCommandCatalog`, `Retail2013CommandCatalog`,
`RawMotionState``src/AcDream.Core/Physics/`.
- `RawMotionState` packer + the three message builders → `src/AcDream.Core.Net/Messages/`.
- Caller wiring → `src/AcDream.App/Rendering/GameWindow.cs` (MoveToState ~8277,
Jump ~8343) — minimal: build a `RawMotionState`, pass new contact/longjump and
jump position params. No new feature body in `GameWindow` (Code Structure Rule #1).
- Tests in the project matching the layer (Rule #6).
## Deferred / unresolved (called out, not guessed)
- **D6 (next slice):** the runtime does not yet construct the exactly-retail
`RawMotionState` (run-vs-walk `forward_command`, hold-key derivation, backward
/ sidestep-left speed normalization). This slice makes the *packer* verbatim;
state construction is `adjust_motion`/`apply_raw_movement` (audit Phase 2).
- **D5 (CONFIRMED 2026-06-30, deferred):** the audit found a real divergence —
retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time`
after an MTS, while `SendPositionEvent` (0x006b4770, AP) stamps all three
(time + position + contact-plane); acdream's `NotePositionSent` stamps all three
after both. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where
retail uses `ShouldSendPositionEvent` (0x006b45e0). Left unchanged this slice (code
comments added at both call sites); recorded as register row **TS-33** and deferred
to a dedicated cadence-port follow-up slice (port `ShouldSendPositionEvent` + split
MTS/AP stamping).
- **D2:** actions-list / current_style packing is structurally supported and
tested, but the runtime emits 0 actions; populated emission is Phase 2+.
- **D9/D8/D10/D11/D12, D7:** later L.1/L.2 slices.
## Bookkeeping (mandatory)
- **Divergence register:** delete the rows for D1, D3, D4 in the same commit that
ports each. If any new deviation is introduced, add its row in the same commit.
- **Roadmap:** update L.2b (wire) and L.1b (catalog) "shipped" notes when landed.
- **Memory:** if a durable lesson emerges (e.g., the default-difference packing
pattern, the dual-catalog seam), capture it.
## Acceptance criteria
- Failing oracle tests written first; then green after implementation.
- `dotnet build` + `dotnet test` green at each commit.
- Every ported algorithm cites the named retail symbol + address in test/comment.
- ACE smoke pass: login, walk/run/sidestep/turn/jump, observe no server
rejection; confirm the over-sent defaults are gone on the wire (dump or capture).
- Divergence register rows for D1/D3/D4 retired; roadmap updated.

View file

@ -0,0 +1,414 @@
# Design — Modern Pipeline side track (MP): baked assets + ECS render world + zero-alloc frame loop
**Date:** 2026-07-05
**Status:** brainstorm-approved; awaiting user spec review
**Origin:** user-commissioned performance side track ("fully modern pipeline, with ECS").
Explicitly NOT M1.5 work — see §12 for the firewall.
**Phase prefix:** `MP0MP5` (MP = Modern Pipeline; avoids collision with the existing
P0 conformance-apparatus plan).
**Implementation cadence:** each MP phase gets its own implementation plan
(`docs/superpowers/plans/`) when it starts; this spec is the umbrella design. The
first plan covers MP0 (+ MP1 scaffold) only.
---
## 1. Motivation + measured baseline
acdream's steady-state dense-town frame rate is ~165 FPS (~6 ms/frame) after the
2026-06-23/24 fixes, versus 300+ FPS in comparable modern titles on the same machine.
Traversal (streaming, portal hops) still hitches. The measured facts this design
builds on (do NOT re-derive; sources in §14):
| Fact | Evidence |
|---|---|
| GPU is nearly idle: ~0.5 ms/frame real GPU time in dense town | 2026-06-23/24 deep-dive; the "12 ms GPU" was a glFinish artifact |
| The frame is CPU-submission-bound | dense-town attribution report; per-cell batch fix took 75→~165 FPS |
| `_datLock` contention (the 30↔200 swing) is FIXED — lockwait 88 ms p95 → 0.2 µs | `536f1c04` + handoff §1 |
| No distance-degrade: every frustum-visible object draws at full detail at any distance (~1,400 draws / ~24,000 instances) | 2026-06-23 handoff §2; `GfxObjDegradeResolver.cs` "always slot 0" comment |
| Parked CPU levers: `WbDrawDispatcher` per-frame static-scenery rebuild (~2.1 ms), cross-frame dispatcher cache (judged HIGH-risk as a *patch* to the frozen renderer) | feedback_render_perf_measurement.md |
| Load hitches come from runtime dat decode (mesh extraction, texture decode, BSP graph construction) + GC | thread-safety investigation; two-tier streaming architecture |
| Runtime dat reads carry an unbounded second-layer cache (`DatDatabaseWrapper._cache`) stacked on DRW's own | `DatCollectionAdapter.cs:119` |
The conclusion the whole design follows from: **the language and the GPU are not the
bottleneck; the data layout and the per-frame/at-load CPU work are.** Modernizing means
(a) moving decode work offline, (b) making the renderer process *changes* instead of
re-describing the scene every frame, (c) flattening hot data, (d) removing frame-loop
allocation.
## 2. Goals & acceptance
1. **Smoothness (primary):** no frame over ~16 ms during any traversal — town walking
while streaming, portal hops, dungeon transitions. Measured: p99 frame time from the
MP0 profiler across a scripted traversal route, plus user-eyes confirmation.
2. **Throughput:** 300+ FPS sustained in the dense-town worst cases (Fort Tethana,
Arwic, Holtburg), Release build, vsync off, user's machine, standing + panning.
3. **Architecture:** baked asset pipeline (pak), Arch-based ECS render world,
zero-allocation steady-state frame loop.
**Non-goals (binding):**
- No Rust. Decision recorded: the bottleneck is architectural, not language; a rewrite
discards ~200K lines of verified retail-faithful ports.
- No ECS in the simulation. `MovementManager`, the animation sequencer, physics
transitions, weenies keep their retail-mirroring OO structure — that correspondence
is the project's debugging methodology.
- No retail-visible behavior changes. Everything here is perf-only
(per feedback_render_perf_not_faithfulness_gated) except MP2, which is a *faithful
port* that retires a known divergence.
- Not M1.5 work. #137/#138/A7 continue independently and win all conflicts.
## 3. Decisions recorded from the brainstorm
| Decision | Choice | Why |
|---|---|---|
| Language | C#/.NET 10 | Perf gap to Rust for optimized code is ~1030%; our gap is architectural multiples; 200K LOC of verified ports retained |
| ECS flavor | **Arch framework** (NuGet `Arch`, ~2.1.x) — render world only | User choice (craft/learning value); fastest .NET archetype ECS; contained + swappable behind our interfaces |
| Bake UX | CLI tool (`acdream-bake`) now; client auto-detect + offer later | Iteration speed while the format churns |
| Sequencing | Measure → smooth → throughput | User priority: smoothness first |
| Cutover style | Per-phase gated slices, legacy path deleted at each gate | A8/N.5 history: big-bang cutovers and lingering dual paths both failed here |
## 4. Architecture overview
```
┌────────────────────────────────────────────────────────────────────┐
│ SIMULATION (unchanged structure) │
│ retail-shaped OO: physics, MovementManager, sequencer, weenies │
└──────────────┬─────────────────────────────────────────────────────┘
│ one seam: transform/lifecycle events (single-owner)
┌──────────────▼─────────────────────────────────────────────────────┐
│ ECS RENDER WORLD (Arch, AcDream.App) [MP3] │
│ entities = drawables; components = Transform, MeshRef, Degrade, │
│ Residency, Flags; systems = TransformSync → Degrade → Visibility │
│ → Upload(dirty ranges) → Draw(MDI) │
└──────────────┬─────────────────────────────────────────────────────┘
│ handles into
┌──────────────▼─────────────────────────────────────────────────────┐
│ ASSET LAYER (pak) [MP1] │
│ acdream-bake (offline, uses DRW + existing tested interpretation) │
│ → pak file → PakReader (mmap, zero-copy, no locks, no decode) │
└──────────────┬─────────────────────────────────────────────────────┘
│ persistent-mapped staging, budgeted uploads
┌──────────────▼─────────────────────────────────────────────────────┐
│ GPU (unchanged N.5 foundation: bindless + MDI, 3 SSBOs) │
└────────────────────────────────────────────────────────────────────┘
```
The N.5 bindless+MDI foundation is kept — it is already the modern half. This design
replaces what feeds it (per-frame rebuild → deltas) and what feeds *that* (runtime
decode → baked pak).
## 5. MP0 — Apparatus (honest measurement)
New `FrameProfiler` (dedicated class in `src/AcDream.App/Diagnostics/`, NOT GameWindow;
CLAUDE.md structure rule 1):
- **CPU frame time**: swap-to-swap delta, ring buffer, p50/p95/p99 + histogram.
- **GPU frame time**: whole-frame `GL_TIME_ELAPSED` query, **never enabled together
with any glFinish-style per-pass bracketing** (the 2026-06-23 lesson — separate
flags, enforced in code, not convention).
- **Per-stage attribution**: `glQueryCounter` (GL_TIMESTAMP) markers per pass — no
glFinish, no nesting conflict with the frame query.
- **Allocation counter**: `GC.GetAllocatedBytesForCurrentThread()` delta per frame on
the update thread + Gen0/1/2 collection counts. This is MP4's before/after meter.
- Toggles via `RuntimeOptions` + DebugPanel (structure rules 4/5); env
`ACDREAM_FRAME_PROF=1`. **Stays in the tree permanently** (unlike the throwaway
`ACDREAM_FPS_PROF`), because every MP gate reads it.
**Gate:** a captured baseline report (dense-town steady + scripted traversal route) with
the CPU 6 ms attributed per stage. If the attribution contradicts §1's assumed split,
the MP phase order is re-sequenced and this doc amended before MP1 starts.
**Gate PASSED 2026-07-05** — see `docs/research/2026-07-05-mp0-baseline.md`.
Verdict: render-side-CPU split CONFIRMED (GPU ≤ ~2.7 ms, stages ≈ 0); steady
medians better than assumed (worst town p50 3.6 ms; Fort Tethana axiom view
not yet re-measured — re-check at the MP2/MP3 gates). AMENDMENT: steady-state
allocation churn (1.53 MB/frame → gen2 GC ~12/s → all town p99/max
violations) is promoted to a first-class smoothness lever: after MP1, one
bounded allocation-triage session fixes top churn sites OUTSIDE the MP3
rewrite surface; sites inside it wait for MP3; the full zero-alloc pass
remains MP4. Teleport hitch quantified: 211 ms worst frame, 75.7 MB
single-frame allocation — MP1's "before" number.
## 6. MP1 — Bake + pak (the smoothness backbone)
### 6.1 The bake tool
`acdream-bake` — new project `src/AcDream.Bake/` (console; registered in
`AcDream.slnx`). Reads the user's dats via DRW + **the same interpretation code the
client uses today**, run offline:
- Mesh extraction (GfxObj/Setup → GPU-layout vertex/index + batch tables) via the
existing ObjectMeshManager build path.
- Texture decode (palette/INDEX16/etc. → RGBA) via the existing TextureHelpers path.
**AMENDED 2026-07-05 (MP1a ground truth):** v1 pak stores **decoded RGBA8 exactly
as the runtime path produces today** (the atlas consumes RGBA8 via
`TextureBatchData` — storing BC7/BC1 would change delivered pixels and violate
this phase's byte-identical conformance gate). BC compression (quarter the disk
+ VRAM, lossy) is an explicit post-conformance option requiring its own visual
gate and user approval, not v1 scope. Mip chains remain runtime-generated
(`GenerateMipmaps` flush) in v1 for the same reason.
- BSPs (physics, cell, drawing) flattened to index-based node arrays (blittable).
- Landblock terrain data (heights/attributes) as blittable arrays.
- EnvCells (geometry + portals + surfaces) as blittable records.
- Degrade tables (per-GfxObj slot `ideal_dist`/`max_dist`/ids) — MP2/MP3 read these.
- **Scenery placement**: `SceneryGenerator` evaluated per landblock at bake; the pak
stores final instance lists (deterministic function of dat data, so bake-safe).
**Extraction prerequisite:** the CPU-side mesh/texture build code currently lives in
`AcDream.App` next to GL code. MP1 moves the GL-free parts into a new
`src/AcDream.Content/` assembly (no GL dependency) that both `AcDream.App` and
`AcDream.Bake` reference. This is a mechanical extraction (move + namespace), not a
rewrite — the same lines keep running. Inventory-doc update required (structure rule 2).
### 6.2 Pak format (v1)
Single file `acdream.pak` next to the dats (path via `RuntimeOptions`):
- Header: magic `ACPK`, format version, **dat iteration stamps** (all four dats) +
bake-tool version. Stale/missing pak at launch → clear error naming the rebake
command (CLI era) → later: auto-offer (per §3).
- TOC: flat sorted array `(assetKey: u64 [type:u8 | fileId:u32 | variant:u24]) →
(offset: u64, length: u32, flags: u32)`; binary-search or hash lookup, O(1)-ish,
loaded once.
- Blobs 64-byte aligned, uncompressed in v1 (mmap + OS page cache + NVMe; LZ4 block
compression is a noted future option, not v1 scope).
### 6.3 Runtime consumption
- `PakReader` in **`AcDream.Content`** (AMENDED 2026-07-05: the spec originally said
Core, but MP1a established the dependency direction Content→Core, and the pak's
blob shapes are the `ObjectMeshData` family living in Content — a Core-resident
reader would be circular. Content is GL-free, so the layering intent is
preserved; Core stays untouched): one `MemoryMappedFile`, `GetBlob(key)`
`ReadOnlySpan<byte>` into the view; typed access via `MemoryMarshal.Cast` over
blittable segments. **No locks anywhere** — immutable mapped memory is
thread-safe to read by construction.
- Streaming workers stop decoding: a landblock load becomes TOC lookups + handing
spans to the GL upload queue. The decode worker pool shrinks to the residual
runtime-decode set (below). Request-key dedup stays in the streaming layer
(cheap loads still shouldn't be duplicate loads).
- **Retires:** runtime DRW decode on the world *render/streaming* hot path; the
unbounded `DatDatabaseWrapper._cache`. The `PhysicsDatBundle` worker pre-read
**stays through MP1MP3** (the physics apply consumes parsed DBObj types until MP4
restructures physics data consumption) and is retired in MP4 when physics reads the
pak's flat arrays directly.
### 6.4 What stays on runtime DRW (v1 scope control)
| Stays runtime | Why |
|---|---|
| UI dat assets (sprites, fonts, LayoutDescs — D.2b) | low volume, cold path |
| Audio waves (`DatSoundCache`) | low volume, already cached |
| Character-appearance palette overlays (ObjDesc) | runtime-parameterized per entity; keeps existing decode + cache |
| Motion tables / animations (sequencer) | sim-side, small, load-once |
`DatCollection` therefore stays in-process, off the hot path. Baking these later is a
listed follow-up, not v1.
### 6.5 Conformance + gate
- **Equivalence test:** load a fixture set (Holtburg blocks + a dungeon + a dense-town
block) through the legacy path and the pak path; diff meshes (vertex/index bytes),
textures (pre-compression pixels), BSP structures, scenery instance lists
field-by-field. Golden = legacy path.
- **Gate:** equivalence green; portal-hop / traversal p99 from MP0 shows the hitch
class gone; user visual gate (no missing/wrong geometry); legacy decode path deleted
from the streaming hot path in the gate commit.
### 6.6 EnvCell dedup — REQUIRED (corrected 2026-07-05 after the full-bake gate)
The runtime deduplicates interior-cell geometry under a **content hash**
(`EnvCellRenderer.GetEnvCellGeomId(environmentId, cellStructure, surfaces)`,
bit 33 set — verbatim WB), NOT under the cell's dat fileId; **many cells share
one geometry** (AC dungeons repeat a small palette of tile geometries thousands
of times).
**The full-bake gate (2026-07-05) proved dedup is mandatory, not optional.**
Baking all 729,888 EnvCells as separate fileId-keyed blobs produced an
**865 GB pak in 52 minutes** — 97% of the blobs were near-duplicate cell
geometry. A prior note here called that duplication "v1-acceptable"; **that was
wrong by ~100×.** Dedup is a v1 requirement.
**The fix (bake-tool only, no pak format change):** dedup at bake time.
Compute each cell's geomId (cheap — reads the EnvCell/CellStruct header, no mesh
extraction), extract each **unique geometry once**, and emit one TOC entry per
cell-fileId all pointing at the one shared blob offset. The TOC already maps
key→offset with no uniqueness constraint on offsets, and `PakReader` is oblivious
to the sharing (pure offset lookup) — so aliasing needs no format or reader
change. Expected result: ~730K blobs → single-digit-thousand unique blobs, pak
865 GB → a few GB, bake 52 min → minutes (all three collapse from the same fix).
**MP1c cutover (unchanged):** `RegisterCell` reads `pak[cellFileId]` → the shared
blob → registers under the runtime geomId; post-load dedup continues exactly as
today. Keying blobs by geomId *directly* remains non-viable (the hash exceeds
PakKey's 56 usable bits) — the fileId-key + aliased-offset scheme is why the
format needs no change.
## 7. MP2 — Distance-degrade port (hide-only first cut)
The already-scoped retail port from the 2026-06-23 handoff §3 (read it + the named
retail sources FULLY before porting — mandatory workflow):
- Retail anchors: `GfxObjDegradeInfo::get_degrade` (`0x0051e4b0`, pseudo-C :293086),
`CPhysicsPart::UpdateViewerDistance` (`0x0050e030`, :275517),
`get_max_degrade_distance` (`0x0051e2d0`, :292918), `Render_DegradeDistance`
user setting (:3270).
- **Hide-only cut (this phase):** per-entity max-draw-distance resolved at spawn from
the degrade table; distance check in the entity walk; player + near NPCs exempt from
hiding (handoff caution). Full per-slot mesh selection deliberately lands in MP3
where the ECS DegradeSystem owns it — accepted small double-touch, avoids building
slot-swap plumbing into a dispatcher MP3 replaces.
- Register bookkeeping: the "no distance-degrade" deviation gets its row updated to
"partial (hide faithful; slot selection pending MP3)" in the same commit; the row is
deleted in MP3's gate commit.
- **Gate:** retail side-by-side is the acceptance (same objects visible/hidden at the
same distances, user-confirmed); drawn-instance count before/after recorded from the
MP0 counters (no invented threshold — retail is the oracle); conformance test on the
`get_max_degrade_distance` math vs decomp.
## 8. MP3 — ECS render world (Arch) + delta submission (the 300 lever)
### 8.1 The world
Arch `World` in `AcDream.App` owning every drawable: statics, baked scenery instances,
live entities, cell objects. Components (all blittable structs):
- `WorldTransform` (position/rotation/scale, cell-relative frame per #145 rules)
- `MeshRef` (pak mesh handle + part index)
- `DegradeState` (current slot, per-slot table handle)
- `Residency` (landblockId, cellId)
- `RenderFlags` (translucent, hidden, player-owned, …)
- `DirtyTag` (tag component; added on change, cleared by Upload)
### 8.2 Systems (fixed order, update thread in v1)
1. **TransformSync** — consumes the simulation's transform/lifecycle events through
ONE seam (single-owner-state rule; the sim never writes render state directly).
Static world entities receive a transform exactly once at residency.
2. **Degrade** — retail `get_degrade` banded slot selection (ideal/max band = no
popping/hysteresis by construction), per entity per frame. Completes the MP2 port;
divergence row deleted here.
3. **Visibility** — frustum + cell/PVS gate (Option A "one gate" preserved from the
render digest — visibility computed once, enforced once).
4. **Upload** — iterates `DirtyTag` entities only; writes changed instance records
into the persistent-mapped SSBO mirror (SoA layout matching the N.5 SSBO), budgeted
per frame.
5. **Draw** — builds the MDI command buffer from the visible set and submits. Instance
*data* is GPU-resident and untouched unless dirty; only commands are per-frame.
GPU compute culling is a measured-need stretch inside this phase, not default scope.
Steady-state per-frame cost becomes: degrade + visibility iteration (linear over
packed arrays) + uploads proportional to *what changed* (a handful of moving
entities), replacing today's full per-frame walk-and-emit (`WalkEntitiesInto` emitting
every MeshRef of every in-frustum entity every frame + the ~2.1 ms scenery rebuild).
### 8.3 Encoded lessons (from the DO-NOT-RETRY tables — binding on implementation)
- Cache/dirty reset gates on actual entity change via tracker (the #53 class).
- Visibility volumes derive from drawn data, never synthetic constants (#119/#128).
- Every GL state the draw uses is set, not inherited (render-self-contained rule).
- Player-owned render state written only at the player chokepoint (#131 class).
- H3 (unbounded live-entity growth across hops) is fixed here structurally: residency
eviction drops render entities (render-only eviction; sim state untouched),
re-materialized on re-entry.
### 8.4 Conformance + gate
- **Instance-set diff:** probe dumps the drawn (meshId, transform, slot) set from the
legacy dispatcher and the ECS path on identical fixture frames; sets must match
exactly (modulo MP2's intended hides).
- **Screenshot diff:** fixed camera poses (Holtburg outdoor set + dungeon set +
dense-town set), old vs new, pixel-identical requirement.
- Bring-up happens behind a temporary flag; **the gate commit deletes the legacy
dispatcher path** (no lingering dual pipeline — N.5 ship-amendment precedent).
- **Gate:** diffs green; dense-town steady FPS ≥ 300 OR the MP0 profile names the next
dominant cost (which then decides MP4/MP5 emphasis); user visual gate.
## 9. MP4 — Zero-alloc frame loop + flat physics data
**Sequencing constraint: starts only after M1.5 #137 (dungeon collision) has landed**
— it touches the same physics data structures.
- Allocation audit driven by MP0's per-frame counter: identify every steady-state
allocation site (closures, LINQ, params arrays, list growth); fix via structs,
pooled buffers, `Span<T>`, preallocated scratch.
- Physics consumes the pak's flattened BSP arrays: `PhysicsDataCache` swaps the
managed BSP node graph for index-walks over blittable arrays. Same traversal
order, same arithmetic — layout change only.
- **Conformance:** the existing physics test suites + the trajectory replay harness
must pass unchanged (bit-identical trajectories); that harness exists precisely for
this class of change.
- **Gate:** steady-state allocations/frame = 0 (measured); physics suites green;
traversal p99 re-measured.
## 10. MP5 — Jobs (stretch, evidence-gated)
Only if the MP0 profile after MP4 still shows the frame short of target: parallelize
the independent stages (Arch has built-in multithreaded query support; visibility and
degrade are embarrassingly parallel once data is flat). **Explicitly skipped with
recorded numbers if targets are already met** — parallelism is a multiplier, not a
goal.
## 11. Testing strategy (summary)
| Apparatus | Built in | Used by |
|---|---|---|
| FrameProfiler (CPU/GPU/alloc, percentiles) | MP0 | every gate |
| Pak equivalence diff (mesh/texture/BSP/scenery) | MP1 | MP1 gate, rebakes |
| Instance-set diff probe | MP3 | MP3 gate |
| Screenshot diff harness (fixed poses) | MP3 | MP3 gate, regressions |
| Physics trajectory replay harness | exists | MP4 gate |
| Existing suites (Core ~1568 tests, conformance sweeps) | exist | every phase (green required) |
`DatConcurrencyStressTests` stays until MP1 retires hot-path DRW, then is scoped to
the residual runtime-DRW set (§6.4).
## 12. Firewall, process, bookkeeping
- **Track identity:** phases commit as `feat(pipeline): MP1 — …`. The roadmap gains a
"Modern Pipeline side track" section; the milestones doc gets an explicit
**freeze-exception paragraph** (this track deliberately reopens the frozen
streaming/rendering subsystems under new architecture, user-authorized 2026-07-05).
- **One milestone rule preserved:** M1.5 remains the active milestone; MP work happens
in dedicated side-track sessions in a separate worktree. M1.5 critical path wins
every conflict; MP4 explicitly queues behind #137.
- **Divergence register:** MP2/MP3 update-then-delete the degrade row as described.
MP1/MP3/MP4 are perf-only and must introduce **zero** rows — any behavioral
difference discovered at a gate is a bug to fix, not a deviation to register.
- **Structure rules honored:** no new GameWindow feature bodies (new classes:
`FrameProfiler`, `PakReader`, `AcDream.Bake`, `AcDream.Content`, ECS systems);
env vars through `RuntimeOptions`; tests in matching layer projects; new project
references documented in the inventory doc.
- **Dependency:** `Arch` (NuGet) added to `AcDream.App` only. License verified at
adoption (expected Apache-2.0); if ever abandoned, vendor the source
(WorldBuilder precedent) or swap for hand-rolled SoA behind the same system
interfaces.
## 13. Risks & mitigations
| Risk | Mitigation |
|---|---|
| Pak staleness confusion (dat patch → wrong assets) | iteration stamps in header; hard refuse + rebake instruction; auto-offer later |
| ECS cutover regresses rendering (the #53/#119/#128 class) | instance-set + screenshot diffs as hard gates; lessons encoded as binding rules (§8.3); legacy deleted only at gate |
| MP1 extraction (`AcDream.Content`) breaks the mesh path | mechanical move, no rewrite; pak equivalence test catches interpretation drift |
| Degrade double-touch (MP2 hide-only, MP3 slots) | accepted, small; hide plumbing (spawn-time max-dist) carries into MP3 unchanged |
| Arch abandonment / license surprise | verify license before adoption; vendorable; swappable behind system interfaces |
| Side track leaks into M1.5 time or files | separate worktree; MP4 hard-queued behind #137; sessions labeled |
| MP0 profile contradicts the assumed cost split | that's MP0's job — re-sequence and amend this doc before MP1 |
| Baked scenery diverges from runtime generation | scenery placement is a pure function of dat data; equivalence diff covers it |
## 14. References
- `docs/research/2026-06-23-fps-distance-degrade-handoff.md` — baseline numbers, degrade
retail anchors, measurement caveats (the §2/§3 this design consumes).
- `claude-memory/feedback_render_perf_measurement.md` — profiler lies + CPU-bound
attribution + parked levers.
- `claude-memory/project_render_pipeline_digest.md`, `project_physics_collision_digest.md`
— DO-NOT-RETRY tables binding on MP3/MP4.
- `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` — the shipped
`_datLock` fix MP1 supersedes (bundle → pak).
- `docs/research/2026-06-09-dat-reader-thread-safety-investigation.md` — DRW read-path
safety + the teardown fix + `DatConcurrencyStressTests`.
- `memory/reference_modern_rendering_pipeline.md` — N.5 SSBO layout the ECS Upload
system mirrors.
- `memory/reference_two_tier_streaming.md` — residency tiers the pak loads plug into.
- Arch ECS: https://github.com/genaray/Arch (NuGet `Arch`).

View file

@ -0,0 +1,167 @@
# Design: verbatim rebuild of the per-frame player physics update loop
**Date:** 2026-07-07 · **Status:** DESIGN APPROVED (implementation deferred to a fresh session)
**Driving issue:** #182 (player-vs-monster crowd collision) + the airborne "stuck in
the falling animation" regression it exposed.
**Brainstorm decisions (user):** verbatim rebuild (highest fidelity); scope = the
per-frame update LOOP, keep everything already faithful; stage the velocity model
first and measure; do the implementation in a NEW session.
---
## 1. Problem (from the user, live)
1. **Crowd jam** — packed in a group of monsters, the player gets stuck and can't
push through / wiggle out. Retail lets you shove monsters aside and keep moving.
2. **Airborne stuck** — jumping up into a crowd, retail lets you land/**glide across**
the monster tops; acdream **hangs in the falling animation** unable to rise or fall.
The #182 CSphere-family port (committed `96ae2740`) was faithful but aimed a layer
too low: it fixed the per-contact sphere *response*, did NOT fix the crowd feel, and
**introduced the airborne-stuck regression** (before #182 the hand-rolled
de-penetration shoved the player up onto monsters).
## 2. Evidence — the traces (the load-bearing part; do not re-derive)
### Retail, player-attributed cdb trace (`tools/cdb/retail-crowd-jump3.cdb`)
Filtered to the local player's `CPhysicsObj` (captured from `set_local_velocity`),
during a jump-into-a-crowd repro:
```
pValidate=1482 pCOLLIDED=188 (12.7%) pSLID=130 (8.8%) pLand=76 → ~78% OK
```
- The retail **local client fully runs player-vs-creature collision** — 76
`CSphere::land_on_sphere` (airborne creature landings), 188 COLLIDED, 130 SLID —
and still nets **~78% OK**. **NOT server-authoritative.** (An earlier v2 unfiltered
read of `land_on_sphere=0` was a false lead — the attributed trace refuted it.)
- The player **glides across / lands on** the crowd via local physics.
### acdream, `ACDREAM_CAPTURE_RESOLVE` JSONL capture (same repro, current #182 build)
Of the player's move-intent resolves (2883 of 41832 total):
| | acdream | retail |
|---|---|---|
| OK (reached target) | **50.9%** | ~78% |
| partial / slid | 26.7% | ~9% |
| **stuck (reverted, no advance)** | **22.4%** | ~13% |
| airborne-stuck | **115 frames** | (glides off) |
acdream jams ~2× harder, and the 115 airborne-stuck frames ARE the falling-animation
bug. In those frames: velocity `(0,0,~18)` (a jump), a **near-horizontal creature
collision normal** (e.g. `(-0.96,-0.25,-0.15)`), the transition reverts (no advance),
and the **sliding normal flips frame-to-frame** — the #137 anti-parallel-absorb wedge.
## 3. Root divergence (verified against decomp + capture)
**Retail** `CPhysicsObj::UpdateObjectInternal` (0x005156b0, pc:283611) recomputes the
player's velocity from the ACTUAL collided movement every frame:
```
newFrame = UpdatePositionInternal(this, dt) // integrate velocity + gravity
transition = CPhysicsObj::transition(this, m_position, newFrame, 0) // collision sweep
if transition == null: set_frame(newFrame); cached_velocity = 0
else:
offset = get_offset(m_position, transition.sphere_path.curr_pos)
cached_velocity = offset / dt // ← VELOCITY = ACTUAL MOVEMENT / dt (0x005158cb-005158ff)
SetPositionInternal(transition)
```
So when the player is **blocked** (resolved ≈ old position), `cached_velocity → ~0`,
gravity resumes next frame, and the player **falls / settles / glides** off the
obstacle. The collision naturally bleeds the velocity — there is no reflection step.
**acdream** uses a different model: `PhysicsBody` integrates velocity/gravity, then
`PlayerMovementController` (`~:1008-1069`) **reflects** the into-wall component on
collision (`Velocity = v + n·k`) and clamps Z on landing. For a straight-up jump into
a creature whose collision normal is horizontal, the reflection barely touches +Z, so
the jump velocity **persists** — the player keeps shoving up into the creature and
hangs. This is the airborne-stuck, and the same "velocity doesn't bleed on block"
shape drives the general 2× jam.
**This velocity model is the centerpiece of the fix.**
## 4. Design — verbatim rebuild of the per-frame loop
Port retail's `UpdateObjectInternal` chain verbatim into a single owner of the
per-frame player physics. **Reuse the already-faithful transition internals** as the
collision primitive — do not touch them.
### 4.1 Retail functions to port (chain of `UpdateObjectInternal`)
| Function | Address | Role |
|---|---|---|
| `CPhysicsObj::UpdateObjectInternal` | 0x005156b0 | per-frame entry: integrate → transition → commit → velocity |
| `CPhysicsObj::UpdatePositionInternal` | (grep) | integrate velocity + gravity + forces → candidate frame |
| `CPhysicsObj::calc_acceleration` | 0x00510950 | gravity / friction / buoyancy |
| `CPhysicsObj::handle_all_collisions` | 0x00514780 | retail's collision velocity response (verify role vs the cached_velocity model) |
| `CPhysicsObj::SetPositionInternal` | 0x00515330 / 0x00515bd0 / 0x00516040 (3 overloads) | commit the transition's resolved position |
| `CPhysicsObj::transition` | (grep) | collision sweep — **maps to acdream `ResolveWithTransition`, reused as-is** |
### 4.2 In scope (rebuilt verbatim)
The per-frame player physics loop: velocity + gravity integration
(`UpdatePositionInternal`/`calc_acceleration`), the transition orchestration
(call → commit), the **velocity-from-movement recompute** (`cached_velocity =
(resolved old)/dt`), and `handle_all_collisions`.
### 4.3 OUT of scope (kept — already faithful, per the user "keep everything faithful")
The transition INTERNALS: `ResolveWithTransition` and everything under it — BSPQuery,
the CSphere/CylSphere collision families (incl. the #182 port), cell membership,
terrain, streaming. Retail's `transition` calls these primitives and so does ours.
### 4.4 Architecture
A new `PhysicsObjUpdater` (or a `PhysicsBody.UpdateObjectInternal(dt)` method) owns
the retail chain verbatim. It **absorbs**: `PhysicsBody`'s ad-hoc gravity/friction
integration AND `PlayerMovementController`'s post-resolve bounce/reflect/clamp block
(`~:1008-1069`). `PlayerMovementController` shrinks to: input → velocity/jump intent →
hand off. The retail velocity-from-movement model **replaces** reflect-and-clamp
wholesale. Register a divergence-register row retiring the reflect model + any
adaptation rows the port introduces.
### 4.5 Staging (velocity model first, measured)
- **Slice 1:** port `UpdatePositionInternal` + `calc_acceleration` + the
`cached_velocity = (resolvedold)/dt` recompute, replacing the integrate+reflect
path. A/B **against the crowd capture**: require the airborne-stuck count → 0 and OK%
to climb toward retail's ~78%. Gate (suites + a visual pass on ordinary
jump/walk/fall) before continuing.
- **Slice 2:** `handle_all_collisions` verbatim (once Slice 1 shows what residual the
velocity model leaves) + any remaining `UpdateObjectInternal` orchestration.
- **Slice 3+:** close the residual ground-jam gap if the velocity model alone doesn't
reach ~78% OK; re-measure each slice against the capture.
## 5. Validation
- **Apparatus (built this session, reuse):** `ACDREAM_CAPTURE_RESOLVE=<path>` JSONL of
every player resolve; the `py` histogram (OK / partial / stuck / airborne-stuck; see
§2). Retail target: **~78% OK, 12.7% COLLIDED, 8.8% SLID, 0 airborne-stuck**. Retail
cdb scripts: `tools/cdb/retail-crowd-jump{2,3}.cdb` (v3 = player-attributed; safe
auto-detach via `validate_transition` terminal + top-level `qd`).
- **Conformance tests:** velocity-from-movement (blocked → velocity → ~0); jump +
gravity integration; the `UpdateObjectInternal` sequence; a crowd replay.
- **Suites stay green:** Core 2603 / App 741 (no regression to walking/stairs/slopes/
walls/streaming — the transition internals are untouched).
- **Visual gates:** (a) crowd glide/land like retail (the #182 symptom); (b) a
regression pass on normal locomotion — flat walking, slopes, stairs, jumping, falling,
wall-slide.
## 6. Risk
Highest-risk option: it replaces the core of every jump/fall/step. Mitigation: the
scope boundary (transition internals untouched), the measured slicing (Slice 1 behind
an A/B compare), and heavy gating. If Slice 1 regresses ordinary locomotion, bisect
within the velocity model before proceeding.
## 7. Open items for the new session (verify during writing-plans)
1. Read `UpdatePositionInternal`, `calc_acceleration` (0x00510950), and
`handle_all_collisions` (0x00514780) fully — confirm the integration + the role of
`handle_all_collisions` vs the cached_velocity model (does retail ALSO reflect, or
is velocity purely movement-derived?).
2. Confirm which `SetPositionInternal` overload the player path uses (3 exist).
3. Decide whether the general ground-jam (22% stuck vs retail 13%) is fully explained
by the velocity model or needs a second divergence (de-penetration / sliding-normal
provenance — the #137/TS-45 wedge family). Measure after Slice 1.
4. #182 stays as the base (user decision). NOTE: until this rebuild lands, the codebase
carries the airborne-stuck regression #182 introduced.
## 8. Pointers
- Physics/collision SSOT: `claude-memory/project_physics_collision_digest.md` (updated
with the #182 + this-investigation banner).
- #182 issue: `docs/ISSUES.md`. CSphere pseudocode: `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`.
- Retail debugger toolchain + the safe-detach recipe: `memory/project_retail_debugger.md` + CLAUDE.md.

View file

@ -0,0 +1,175 @@
# Design: verbatim port of retail's per-remote physics tick so packed monsters de-overlap
**Date:** 2026-07-07 · **Status:** DESIGN APPROVED (user: "I want retail behavior. So make it work.")
**Driving report (user, side-by-side vs retail on the SAME ACE):** monsters packed around the
player **overlap** in acdream (arms interpenetrating); in the retail client they barely overlap.
This is the crowd "no room to slide out" feel — the room is created by the monsters *not*
occupying the same space.
---
## 1. Verified root cause (decomp + source, not guessed)
Research workflow `wf_d2ff782f-9cb` (4 read-only agents) + direct source reads settle it. Retail
anchors are into `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
### Retail: ONE unified per-object tick that collides every creature locally
- **`CPhysics::UseTime`** (0x00509950, pc:271640) walks **every** `CPhysicsObj` in the object
table and calls `update_object → UpdateObjectInternal` on each — **no fork** between the player
and remote creatures. The player only gets an *extra* camera callback.
- **`UpdateObjectInternal`** (0x005156b0) runs the full `transition` (collision sweep) for any
active object with spheres whose origin moved. `CObjCell::find_obj_collisions` (0x0052b750,
pc:308916) iterates the cell's shadow list; creature-vs-creature collision is exempt **only**
for a *viewer* mover or an *IGNORE_CREATURES* mover. So **moving remote creatures de-overlap
against each other, client-side.**
- A remote creature stays "active" (transient bit 0x80) because the motion/animation system feeds
velocity every tick via `apply_interpreted_movement` (0x00528600) → `set_local_velocity`
(0x005114d0) → `set_velocity` (0x005113f0, which sets 0x80).
### Retail: the server position is a GENTLE DR target, not a hard-snap
- **`SmartBox::HandleReceivedPosition`** (0x00453fd0) routes a remote through
**`CPhysicsObj::MoveOrTeleport`** (0x00516330):
- newer teleport-TS OR no cell → **hard snap** (`SetPosition`).
- has-velocity AND `player_distance < 96`**`InterpolateTo`** (queue a DR node).
- has-velocity AND `player_distance ≥ 96`**far snap** (`SetPositionSimple`).
- **`InterpolationManager::adjust_offset`** (0x00555d30) catches up toward the queued server
position at **≤ 2× the creature's max animation speed × dt**. The per-tick collision-resolved
position is **stored and persists** (`SetPositionInternal(transition)` writes
`sphere_path.curr_pos`, not the raw target); the *next* server update re-aims the catch-up
**from wherever collision left the creature**. A `node_fail_counter` blips to the server point
only after ~5 genuinely-stuck frames (`CREATURE_FAILED_INTERPOLATION_PERCENTAGE=0.3`,
`MIN_DISTANCE_TO_REACH_POSITION=0.2`).
**Net retail behavior:** de-overlap is **proactive** (movement is collision-stopped *before*
creatures interpenetrate) and **persistent** (the resolved position is never overwritten by a
snap-into-overlap). ACE broadcasts each creature's own pathed (overlapping) position with no
server-side separation; **the client spreads them.** (ACE/holtburger source not checked out —
empty submodule dirs — but the decomp + the #182 live cdb trace establish client-responsibility.)
### acdream: the two divergences (both verified in source)
1. **NPC (monster) `UpdatePosition` HARD-SNAPS to the raw server position.** `GameWindow.cs:5925-5926`:
`if (!snapSuppressedByStick) rmState.Body.Position = worldPos;` — only suppressed while a sticky
melee lease is armed. So a packed monster teleports into the overlapping server position every
UP, **overwriting** whatever de-penetration the per-tick sweep achieved. The sweep resolves
*movement*, not a static overlap it gets snapped into, so the overlap persists.
2. **A two-path fork retail doesn't have** (`GameWindow.cs:10076`):
- **Path A** — grounded **player** remotes (`IsPlayerGuid` && !Airborne, `:10076-10311`): advance
via the `RemoteMotionCombiner.ComputeOffset` interp catch-up, **`ResolveWithTransition` is
deliberately NOT called** (`:10274-10281` "collision is the sender's problem" — factually
wrong per the decomp). Player remotes never de-overlap.
- **Path B** — NPCs/monsters + airborne player remotes (`:10312-10698`): DO call
`ResolveWithTransition` (`:10558`) with `moverFlags: EdgeSlide` (not IsViewer/IgnoreCreatures),
so the `CollisionExemption` gate already lets a monster collide against other creatures. BUT
the NPC UP hard-snap (#1) overwrites its result, and the swept displacement is only
`preIntegrate→postIntegrate` (tiny between UPs), so it can't undo a snapped-in static overlap.
**So the collision math is already faithful and present.** The bug is the **reconciliation**
(hard-snap) + the **fork** (player remotes skip the sweep). Retail's gentle catch-up + fork-free
tick is what makes the resolved position persist.
---
## 2. Design — port retail's unified per-remote tick + MoveOrTeleport reconciliation
Make every remote creature run the retail `UpdateObjectInternal` shape: **interp-catch-up delta +
anim velocity → `ResolveWithTransition` sweep → store the collision-resolved position**, with the
server `UpdatePosition` fed in as a **gentle DR target** (MoveOrTeleport), not a hard-snap. This
collapses Path A and Path B into one and lets the per-tick de-overlap persist.
### 2.1 Retail functions being ported (chain)
| Function | Address | Role | acdream target |
|---|---|---|---|
| `CPhysics::UseTime` | 0x00509950 | walk ALL objects, tick each | the `_animatedEntities` per-tick loop `GameWindow.cs:10019` (collapse the fork) |
| `CPhysicsObj::MoveOrTeleport` | 0x00516330 | teleport-snap / near-interpolate(<96) / far-snap(≥96) | the remote `UpdatePosition` handler `GameWindow.cs:5879+` (extend the player-remote routing to NPCs) |
| `InterpolationManager::adjust_offset` | 0x00555d30 | speed-capped catch-up (≤2×max_speed·dt) + fail-counter blip | acdream `InterpolationManager` (`rm.Interp` — already present) |
| `UpdateObjectInternal` | 0x005156b0 | integrate → transition → commit resolved pos | the unified per-remote tick (reuse `ResolveWithTransition`) |
### 2.2 In scope (rebuilt verbatim)
- **NPC `UpdatePosition` reconciliation:** replace the hard-snap (`GameWindow.cs:5925-5926` +
the orientation/velocity snaps `:5952-5967+`) with the retail `MoveOrTeleport` routing already
written for player remotes (`:5822-5848`): far (>96 m) → snap + clear queue; near → `Interp.Enqueue`
the server waypoint; teleport/parent → snap. Keep the sticky-suppression escape (it is retail's
"stuck → server correction can't fight the armed stick" behavior, TS-41/44).
- **Unify the tick:** every remote (Path A player *and* Path B NPC) advances by the interp
catch-up (`Interp.AdjustOffset`) **composed into a `MotionDeltaFrame`** (interp + sticky), then
runs `ResolveWithTransition` and **stores the resolved position** — the pattern Path B already
uses for the sticky delta at `:10521-10528`. Path A gains the sweep; Path B gains the interp
catch-up as its movement source (so the swept displacement IS the catch-up).
- **Setup-derived mover sphere:** replace the hard-coded human dims `0.48f/1.835f`
(`GameWindow.cs:10551-10556`) with the creature's own Setup sphere / `ObjScale` so differently
sized creatures de-overlap at their true radii (retail sizes the mover sphere from its own Setup
via `init_sphere`).
- The `node_fail_counter` blip watchdog (already in `InterpolationManager`) — keep; it is the
retail "genuinely blocked → snap to server after ~5 frames" escape.
### 2.3 OUT of scope (kept — already faithful)
- The **transition internals** (`ResolveWithTransition` and below — BSPQuery, the CSphere/CylSphere
families incl. the #182 port, `CollisionExemption`, cell membership). Retail's remote tick calls
these primitives; so does ours. Untouched.
- The **shadow registry** (`ShadowObjects.Register`/`UpdatePosition`, `GameWindow.cs:4171/5665`) —
every remote creature is already registered + live-synced (retail's `AddShadowObject`/`change_cell`
equivalent). No new registration.
- The **R5 managers** (`MovementManager`/`MoveToManager`/`TargetManager` + the `Motion.PositionManager`
Sticky/Constraint facade). A verbatim `UpdateObjectInternal` ticks exactly these — the port ticks
them, doesn't rewrite them.
- The **#182 local-player rebuild** (this session) — orthogonal; stays as the base.
### 2.4 Architecture (Code Structure Rule 1: no new feature bodies in GameWindow)
`GameWindow.cs` is already >10k lines and owns the remote tick. The unified per-remote update body
goes into a **new `RemotePhysicsUpdater`** (`src/AcDream.App/Physics/` or `Rendering/`) that takes
a `RemoteMotion` + dt + the `PhysicsEngine` and runs the retail `UpdateObjectInternal` chain
(interp delta → sweep → commit). `GameWindow`'s per-tick loop shrinks to: resolve the entity →
`RemotePhysicsUpdater.Tick(rm, dt)` → write `entity.SetPosition(rm.Body.Position)`. The
`UpdatePosition` `MoveOrTeleport` routing is extracted into a `RemotePhysicsUpdater.OnServerPosition`
(or a small `RemoteReconciler`) so the NPC and player-remote paths share ONE implementation.
### 2.5 Staging (measured, de-overlap first)
- **Slice 1 — NPC reconciliation (the reported symptom):** replace the NPC hard-snap with the
`MoveOrTeleport` gentle catch-up (Enqueue), and drive Path B's body advancement from the interp
catch-up so its existing sweep resolves the catch-up movement against other creatures. Gate:
packed monsters visibly de-overlap side-by-side vs retail; no remote jitter/rubber-band regression.
- **Slice 2 — unify the fork:** collapse Path A into the same `RemotePhysicsUpdater.Tick` (player
remotes gain the sweep → packed *players* de-overlap too). Extract the shared updater
(§2.4). Gate: remote player crowd de-overlaps; observed-motion (walk/run/jump/land) unchanged.
- **Slice 3 — Setup-derived mover sphere** (§2.2). Gate: large/small creatures de-overlap at true
radii.
---
## 3. Validation
- **Conformance tests (Core/App):** a `RemotePhysicsUpdaterTests` harness — two registered creature
shadows converging on a point, driven through the updater, assert they settle at
contact-distance (sum of radii), not overlapping; a `MoveOrTeleport` routing test (near→enqueue,
far→snap, teleport→snap); a reconciliation persistence test (a de-penetrated position survives
the next server UP instead of snapping into overlap).
- **Suites stay green:** Core 2617 / App 741 — no regression to remote animation (walk/run/jump/
land/turn), dead-reckoning smoothness, sticky melee (#171), or the #182 player fix.
- **Visual gate (the acceptance test):** side-by-side vs the retail client on the same ACE — packed
monsters spread to retail spacing (arms no longer interpenetrating); remotes don't jitter,
rubber-band, or desync from the server; sticky-melee facing (#171) unbroken.
- **Apparatus:** `ACDREAM_REMOTE_VEL_DIAG` (existing) for the UP/seq pace; a `[remote-deoverlap]`
probe optional (rendered pos vs raw server UP + neighbor overlap depth) if a residual needs it.
## 4. Risk
Touches the **R4/R5 remote dead-reckoning arc** (a shipped area): the NPC hard-snap and the
two-path fork. Mitigations: the collision + shadow-registry + R5-manager substrate is UNTOUCHED
(the change is the reconciliation + fork), the sticky-suppression escape is preserved (protects
#171), staged so the NPC de-overlap lands + gates first, and `node_fail_counter` keeps a genuinely
stuck remote from freezing. Perf: N creatures × a sweep per tick — Path B already does this for
NPCs; Slice 2 adds it for player remotes (bounded by the visible-crowd count).
## 5. Register bookkeeping
- Retire the "remotes skip the transition / server already collision-resolved" adaptation (the
`GameWindow.cs:10089/10275` premise, filed under #40 / ISSUES.md ~:4899) — it's the exact
divergence whose "Risk if assumption breaks" is the arms-overlap. Same commit that lands Slice 2.
- Any adaptation the port introduces (e.g. the NPC MoveOrTeleport near-distance constant, the
sticky-suppression retention) gets its row in the same commit.
## 6. Open items to verify during implementation
1. Confirm Path B's `ResolveWithTransition` sweep, once driven by the interp catch-up delta,
actually de-penetrates a converging neighbor (vs the tiny-displacement no-op) — the
`RemotePhysicsUpdaterTests` converging pair pins this before the visual gate.
2. Confirm the NPC `Interp.Enqueue` path composes with `TickRemoteMoveTo` / `ServerVelocity` /
the sticky delta without double-driving the body (the `RemoteMotionCombiner` REPLACE dichotomy).
3. Decide whether Slice 1 needs Path B to *also* route through `Interp` (likely) or whether feeding
`ServerVelocity` into the existing integrate+sweep suffices — measure with the converging-pair test.

View file

@ -0,0 +1,160 @@
# Design: #185 — local player jams half-way up outdoor stairs (fix)
**Date:** 2026-07-08 · **Status:** DESIGN v2 (root cause re-confirmed via live apparatus) — impl not started
**Milestone:** M1.5
> **v2 note (2026-07-08):** v1 of this spec chased a collision-*response* cause ("grounding
> retention at a convex coplanar seam", Approach A). Live capture #3 (`[cp-write]` +
> `[entity-source]` + full `[bsp-test]` object map) **disproved** that and pinned the REAL root:
> a uint32 overflow in the shadow-registry part-id that silently drops some landblock objects'
> collision geometry. The wedge is a faithful *symptom*. v2 supersedes v1 entirely.
---
## 1. Symptom
Running the LOCAL player up the outside stairs of a house-on-stilts, you hit an invisible wall
"in the middle of the stairs" (user's words) — the steps look unbroken, you slide sideways and
cannot advance, and a jump clears it. Jam ≈ world (132, 77.9, 61.5), landblock `0xf682`,
cell `0xF682002C`.
## 2. Root cause — CONFIRMED (live apparatus, code-verified)
### 2.1 The bug: a uint32 overflow in the shadow-registry part-id
`src/AcDream.App/Rendering/GameWindow.cs:7951`, in the per-landblock-entity collision
registration loop:
```csharp
uint partId = entity.Id * 256u + partIndex; // OVERFLOWS uint32
_physicsEngine.ShadowObjects.Register(partId, meshRef.GfxObjId, ...);
```
Landblock entity ids carry a **class prefix in the top byte** (`0x40`/`0x80`/`0xC0`; e.g.
`0x40F68221`, `0xC0F68221`). For any id ≥ `0x01000000`, `* 256` (== `<< 8`) **overflows uint32
and drops the prefix byte**: `0x40F68221 * 256` and `0xC0F68221 * 256` both truncate to
`0xF6822100`. So **different-class entities that share the low 24 bits collide on the same shadow
part-id.** `ShadowObjectRegistry.Register` calls `Deregister(partId)` before inserting
(`ShadowObjectRegistry.cs:117`), so the second registrant **silently deletes the first's
collision geometry** (last-writer-wins). Rendering uses the full 32-bit `entity.Id` (no overflow),
so every step still *draws*.
A sibling scheme in the Setup cyl/sphere path (`shapeId = entity.Id + K*0x10000000u`, `:8032/8068/8093`)
is a lesser variant of the same "synthetic per-part id" anti-pattern (can collide across entities),
but the #185 stairs are BSP-bearing so they hit the `*256` overflow specifically.
### 2.2 Evidence (capture #3, `ACDREAM_PROBE_CONTACT_PLANE` + `ACDREAM_PROBE_BUILDING`)
- **`[cp-write]`** attributes the contact-plane loss to `Transition.FindTransitionalPosition` (the
forward main-sweep) 530× — i.e. the mover loses grounding at the seam and drops into step-down →
the `PrecipiceSlide` wedge (the "invisible wall"). This is the SYMPTOM.
- **`[bsp-test]` object map** — the collision set has stair steps at world Y = 74.24→77.25 (lower
flight) and 79.25→80.25 (upper flight), with **NO objects at Y≈77.75/78.25/78.75**. The player
climbs the lower flight, floats past its top tread (Y=77.495) into the collision hole, finds no
ground, and wedges. The user confirms the steps there are **visually present** ("steps look
unbroken").
- **`[entity-source]` part-id collision analysis** — landblock `0xF682` has **23 part-ids with a
collision** (>1 entity → same part-id), including the stair runs themselves:
`0xF6822100 ← {0x40F68221, 0xC0F68221}`, `0xF6821200 ← {0x40F68212, 0xC0F68212}`,
`0xF6822000`, `0xF6822200`, … Overflow check: `0x40F68221*256 & 0xFFFFFFFF == 0xF6822100 ==
0xC0F68221*256 & 0xFFFFFFFF`.
### 2.3 Why the wedge is a faithful symptom (do NOT fix it)
Once a step's collision is silently dropped, there is genuinely no ground there, so
`PrecipiceSlide` firing at the walkable edge is *correct* retail behavior for a real edge. The
`PrecipiceSlide` math, the `SetSlidingNormal` Z-zero, and the multi-object search are all
retail-faithful (decomp cross-check `wf_3c1120c4-a04`). Fixing the registration makes the geometry
present, the mover climbs normally, and the wedge never fires. **No change to the frozen collision
internals.**
## 3. The fix — Option A: register multi-part landblock entities via `RegisterMultiPart`
**Retail model (the anchor).** A multi-part physics object is one `CPhysicsObj` + `CPartArray`;
collision registration is `CPhysicsObj::add_shadows_to_cells` (0x00514ae0) →
`CPartArray::AddPartsShadow`, which walks the part array and writes each part's shadow into the
flooded cells, **all keyed to the single object** — no synthetic per-part id.
`ShadowObjectRegistry.RegisterMultiPart` is the direct port (its doc cites exactly this).
**Change (one call site — the per-entity registration block, `GameWindow.cs` ~78768100).**
Replace the two synthetic-id `Register(...)` schemes with a single `RegisterMultiPart` per entity:
1. Build a `List<ShadowShape>` for the entity, honoring retail's binary dispatch (BSP-xor-cyl,
`feedback_retail_binary_dispatch`):
- If any mesh-ref part has a physics BSP → add one **BSP** `ShadowShape` per BSP part
(`GfxObjId = meshRef.GfxObjId`, `LocalPosition/LocalRotation/Scale` decomposed from
`meshRef.PartTransform`, `Radius = part BoundingSphere.Radius × scale`).
- Else if the Setup has CylSpheres/Spheres/Radius → add **Cylinder** `ShadowShape`s
(same local-offset/scale math as today, but as `LocalPosition` relative to the entity — the
registry rotates by `entityWorldRot` internally).
2. `if (shapes.Count > 0 && !entity.IsBuildingShell) ShadowObjects.RegisterMultiPart(entity.Id,
entity.Position, entity.Rotation, shapes, state:0, flags:None, origin.X, origin.Y,
lb.LandblockId, seedCellId: entity.ParentCellId ?? 0)`.
3. Keep the `IsBuildingShell` skip (building shells collide via the building channel — retail).
This uses each entity's **unique 32-bit `entity.Id`** as the sole key — no `*256`, no
`+K*0x10000000`, no overflow, no collision — and **unifies the codebase on the one faithful
multi-part registration path** (the door + every server entity already use it). It removes the
redundant per-part loop.
**Despawn.** Landblock unload must `Deregister(entity.Id)` (not the old synthetic part-ids). Verify
the unload path deregisters by `entity.Id`; adjust if it currently targets `partId`.
**Explicitly NOT changed:** `PrecipiceSlide`, `SetSlidingNormal`, the collision response, the
step-down chain — all verified faithful; the symptom disappears once geometry is present.
## 4. Alternatives considered
- **B — widen the shadow-registry key to `ulong`** (`partId = ((ulong)entity.Id<<8)|partIndex`).
Keeps the non-retail "each part is its own shadow object with a synthetic id" model, merely
de-overflowed; fragments one retail object into N pseudo-objects; leaves two parallel
registration paths. Rejected — less architectural, less faithful.
- **v1 Approach A (grounding retention)** — disproved (§ v2 note): the continuation isn't in the
collision set to retain grounding on.
## 5. Test strategy
- **Red→green unit pin (Core):** `ShadowObjectRegistryOverflowTests` — register two entities whose
ids collide under the OLD `*256` scheme (e.g. `0x40F68221`, `0xC0F68221`) as multi-part; assert
BOTH remain queryable in their cells (OLD scheme: one overwrites the other; NEW: both present).
Drive via `RegisterMultiPart` + `GetObjectsInCell`.
- **App-layer pin:** a focused test over the registration helper (extracted if needed) that feeds
two colliding-id multi-part entities and asserts two distinct registrations survive. If the
registration logic is buried in `GameWindow`, extract the shape-list build + register into a
small testable method (`LandblockEntityCollisionRegistrar`) per Code-Structure-Rule 1.
- **Keep the dat-free clean-climb replay** (`Issue185OutdoorStairsSeamReplayTests`, already written
+ passing) as a regression pin (continuous stairs must climb).
- **Regression:** `ShadowObjectRegistryTests`, `ShadowObjectRegistryMultiPartTests`,
`DoorBugTrajectoryReplayTests`, `Issue137*`, `CellarUp*`, `SphereCollisionFamilyTests` green;
full `dotnet build` + `dotnet test`.
- **Live gate (acceptance, user):** walk up the FULL staircase — no jam, no jump; plus a spot check
that torches/trees/other statics still collide (the registration path is shared).
## 6. Apparatus / fixtures (captured this session)
- `185-recapture3.jsonl` + task `.output` (`[cp-write]`, `[entity-source]`, `[bsp-test]` object map).
- Overflow analysis (23 collisions) reproducible from the `[entity-source]` log.
- gfxobj dumps `Fixtures/issue185/0x01000AC5.gfxobj.json` (+ `0x01000ACA`) for the replay.
## 7. Bookkeeping (in the fix commit)
- Register: this is a bug fix (not a new deviation); if a row implied the old per-part id scheme,
none does — no register row needed, but note the fix in the ISSUES close.
- `docs/ISSUES.md`: move #185 to Recently closed with the SHA.
- `claude-memory/project_physics_collision_digest.md`: banner — #185 was a REGISTRATION overflow
(part-id `*256` uint collision), NOT a collision-response bug; the wedge was a faithful symptom.
DO-NOT-RETRY: don't chase the wedge/precipice; the fix is the registration id.
## 8. Acceptance
- Local player walks up the full outdoor staircase, no jam/jump.
- No collision-id collisions in a landblock (the 23-collision analysis returns 0 after the fix).
- Other landblock statics (torches/trees/buildings) still collide; suites + build/test green.
## 9. DO-NOT-RETRY
- Do NOT touch `PrecipiceSlide` / `SetSlidingNormal` / step-down — the wedge is a faithful symptom
of missing geometry, not the cause.
- Do NOT "widen the key" (Option B) — it preserves the non-retail synthetic-per-part-id model.
- Do NOT reintroduce a synthetic per-part id (`*256` or `+K*0x10000000`) — the retail model is one
object id + a part array (`RegisterMultiPart`).

View file

@ -38,6 +38,7 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
</ItemGroup>

View file

@ -0,0 +1,213 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AcDream.Core.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>Stage indices for per-frame CPU attribution.</summary>
public enum FrameStage
{
/// <summary>Whole OnUpdate body (simulation + streaming apply).</summary>
Update = 0,
/// <summary>WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.</summary>
Upload = 1,
/// <summary>ImGui Render (dev overlay).</summary>
ImGui = 2,
}
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
/// bool check per frame when off.
///
/// <para>Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under <c>ACDREAM_WB_DIAG=1</c>
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).</para>
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
private readonly FrameStatsBuffer[] _stageUs;
private readonly long[] _stageAccumTicks;
private readonly bool _wbDiagActive =
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
private long _lastAllocBytes;
private long _lastReportTicks;
private int _gc0Base, _gc1Base, _gc2Base;
private int _framesInWindow;
private int _ownerThreadId;
private bool _threadWarned;
private bool _wbDiagNoticePrinted;
private bool _wasEnabled;
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
_stageAccumTicks = new long[StageCount];
}
/// <summary>
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
/// </summary>
public void FrameBoundary(GL gl)
{
bool enabled = RenderingDiagnostics.FrameProfEnabled;
if (!enabled)
{
if (_wasEnabled)
{
// Dispose (not just Stop) so a later re-enable rebuilds the
// query ring fresh — a kept instance would poll slots left
// pending from BEFORE the pause and report temporally stale
// GPU samples. Safe here: this runs at the top of OnRender
// with the GL context current.
_gpuTimer?.Dispose();
_gpuTimer = null;
_wasEnabled = false;
_lastBoundaryTimestamp = 0;
}
return;
}
if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId;
else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId)
{
_threadWarned = true;
Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable");
}
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
// baselines, emit nothing. Clear any stage ticks a StageScope
// disposed after toggle-off may have accumulated mid-pause —
// EndStage still runs on scopes that were live when the flag
// flipped, and that partial delta must not leak into the first
// re-enabled frame.
_wasEnabled = true;
_lastReportTicks = DateTime.UtcNow.Ticks;
Array.Clear(_stageAccumTicks);
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)");
}
}
else
{
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
_cpuUs.Push(cpuUs);
_allocBytes.Push(allocNow - _lastAllocBytes);
for (int i = 0; i < StageCount; i++)
{
_stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
if (_gpuTimer?.FrameBoundary() is long gpuUs)
_gpuUs.Push(gpuUs);
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
int gc0 = GC.CollectionCount(0) - _gc0Base;
int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base;
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0;
_cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset();
for (int i = 0; i < StageCount; i++) _stageUs[i].Reset();
}
}
/// <summary>
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
/// Zero-cost (default scope) when the profiler is off.
/// </summary>
public StageScope BeginStage(FrameStage stage)
=> RenderingDiagnostics.FrameProfEnabled
? new StageScope(this, stage, Stopwatch.GetTimestamp())
: default;
internal void EndStage(FrameStage stage, long startTimestamp)
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
/// <summary>Pure report formatter — unit-tested; invariant culture.</summary>
public static string FormatReport(
int frameCount,
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
FrameStatsBuffer alloc, int gc0, int gc1, int gc2,
FrameStatsBuffer[] stages)
{
var ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder(256);
sb.Append("[frame-prof] n=").Append(frameCount);
sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}",
cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0,
cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0);
if (gpuActive)
sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}",
gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0);
else
sb.Append(" | gpu=off(wbdiag)");
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}",
alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2);
string[] names = { "upd", "upl", "imgui" };
for (int i = 0; i < stages.Length && i < names.Length; i++)
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}",
names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);
return sb.ToString();
}
public void Dispose() => _gpuTimer?.Dispose();
}
/// <summary>Disposable stage scope; default instance is a no-op.</summary>
public readonly struct StageScope : IDisposable
{
private readonly FrameProfiler? _owner;
private readonly FrameStage _stage;
private readonly long _start;
internal StageScope(FrameProfiler owner, FrameStage stage, long start)
{
_owner = owner; _stage = stage; _start = start;
}
public void Dispose() => _owner?.EndStage(_stage, _start);
}

View file

@ -0,0 +1,69 @@
using System;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
/// (microseconds or bytes) with percentile/max over the current window.
/// Pure and allocation-free after construction: <see cref="Percentile"/>
/// sorts into a preallocated scratch array, so the 5-second report path
/// allocates nothing. Not thread-safe — owned by the window loop thread.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameStatsBuffer
{
private readonly long[] _samples;
private readonly long[] _scratch;
private int _cursor;
private int _count;
public FrameStatsBuffer(int capacity)
{
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
_samples = new long[capacity];
_scratch = new long[capacity];
}
public int Count => _count;
public void Push(long value)
{
_samples[_cursor] = value;
_cursor = (_cursor + 1) % _samples.Length;
if (_count < _samples.Length) _count++;
}
public void Reset()
{
_cursor = 0;
_count = 0;
}
/// <summary>
/// Nearest-rank percentile over the current window: element at
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
/// </summary>
public long Percentile(double q)
{
if (_count == 0) return 0;
Array.Copy(_samples, _scratch, _count);
Array.Sort(_scratch, 0, _count);
int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
if (rank < 1) rank = 1;
if (rank > _count) rank = _count;
return _scratch[rank - 1];
}
public long Max()
{
if (_count == 0) return 0; // documented empty behavior, matches Percentile
// Seed from the first live sample so all-negative windows (the alloc
// channel can go negative if the boundary ever crosses threads) return
// the true max instead of clamping to 0. Slots [0.._count) are always
// the live window regardless of ring wraparound.
long max = _samples[0];
for (int i = 1; i < _count; i++)
if (_samples[i] > max) max = _samples[i];
return max;
}
}

View file

@ -0,0 +1,87 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
private const int RingDepth = 4;
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
public GpuFrameTimer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
for (int i = 0; i < RingDepth; i++)
_queries[i] = _gl.GenQuery();
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// </summary>
public long? FrameBoundary()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
}
public void Dispose()
{
Stop();
for (int i = 0; i < RingDepth; i++)
_gl.DeleteQuery(_queries[i]);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,619 @@
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
namespace AcDream.App.Physics;
/// <summary>
/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted
/// verbatim from <c>GameWindow.TickAnimations</c> (Code Structure Rule 1: no
/// new feature bodies in the &gt;10k-line <c>GameWindow</c>). One instance is
/// owned by <c>GameWindow</c> and called once per animated remote entity per
/// frame, from inside the same guard the body used to live under
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
///
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
/// incl. the local player's own cylinder — kept on GameWindow) and
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>
/// </summary>
internal sealed class RemotePhysicsUpdater
{
// Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller).
private const double ServerControlledVelocityStaleSeconds = 0.60;
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
internal RemotePhysicsUpdater(
AcDream.Core.Physics.PhysicsEngine physicsEngine,
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
{
_physicsEngine = physicsEngine;
_getSetupCylinder = getSetupCylinder;
_applyServerControlledVelocityCycle = applyServerControlledVelocityCycle;
}
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
/// <summary>
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
/// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor).
/// </summary>
public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
// movement managers' UseTime. This is where this entity, as a
// watched target, pushes its position to its voyeurs (any entity
// moving-to it), and where its own target-info staleness times
// out. Runs for every remote regardless of the grounded/airborne
// branch below (which drive MoveToManager.UseTime via
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
rm.Host?.HandleTargetting();
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
// problem" premise) is GONE — every remote now runs the SAME catch-up +
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
// player/remote fork; the only surviving player/NPC split is the omega
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
//
// Stop detection stays explicit on packet receipt (UpdateMotion
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
// The bare block scopes this update's locals (formerly the else body).
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
//
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
// Otherwise the force-OnWalkable + apply_current_movement
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
// and gravity never gets to integrate the arc. The airborne
// body keeps the launch velocity from the VectorUpdate;
// UpdatePhysicsInternal below applies gravity each tick;
// the next UpdatePosition snaps to the new ground location
// and re-grounds.
if (!rm.Airborne)
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// #184 (2026-07-07): a grounded remote carries NO translation
// velocity. Its per-tick movement is the interp CATCH-UP toward
// the MoveOrTeleport-queued server waypoint (computed at the
// sticky-compose site below), which the KEPT ResolveWithTransition
// sweep de-overlaps against neighbours — and the resolved position
// is written back into the SHADOW (below) so the de-overlap
// persists and neighbours collide against the resolved body, not
// the raw server pos. This REPLACES the old synth-velocity model
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
// leg — a remote translates by adjust_offset and the UP is a gentle
// target. As of #184 Slice 2b this grounded model is the SINGLE
// remote path (players + NPCs) — retail has no fork.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
// Stale server-velocity → stop the locomotion CYCLE (the legs).
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
// so a scripted-path NPC that stops server-side drops out of its
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
// anim from ServerVelocity, independent of Body.Velocity.
bool moveToArmed = rm.MoveTo is
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
bool stickyArmed =
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
&& !moveToArmed && !stickyArmed)
{
double velocityAge = nowSec - rm.LastServerPosTime;
if (velocityAge > ServerControlledVelocityStaleSeconds)
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
_applyServerControlledVelocityCycle(
serverGuid,
ae,
rm,
System.Numerics.Vector3.Zero);
}
}
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
// MovementManager::UseTime per tick, UpdateObjectInternal call
// @0x00515998) — UseTime runs HandleMoveToPosition /
// HandleTurnToHeading (steering + arrival + fail-distance),
// dispatching its per-node locomotion (turn / RunForward) through
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
TickRemoteMoveTo(rm);
}
else
{
// Airborne — keep Active flag (so UpdatePhysicsInternal
// doesn't early-return) but DON'T set Contact / OnWalkable.
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2: integrate rotation manually per tick. We can't
// rely on PhysicsBody.update_object here — its MinQuantum
// gate (1/30 s) causes it to SKIP integration when our
// 60fps render dt (~0.016s) is below the quantum, meaning
// rotation never advances. Measured snap per UP was ~129°
// = the full expected 1s × 2.24 rad/s, confirming zero
// between-tick rotation.
//
// Manual integration matches retail's FUN_005256b0
// apply_physics (Orientation *= quat(ω × dt)). Use
// ObservedOmega derived from server UP rotation deltas so
// the rate exactly matches server physics — hard-snap on
// next UP becomes invisible by construction.
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
// world-frame (pre-multiply, Concatenate) application inherited from the
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
// the wire TurnCommand) must carry the turn or the body would not rotate
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
// is preserved. For an upright body + a yaw (world-Z) omega the two
// multiplication orders commute, so this fork is faithful, not cosmetic.
// calc_acceleration zeroes Body.Omega for grounded bodies before
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
// (a wire-set Body.Omega would otherwise double-integrate on top of the
// manual rotation).
rm.Body.Omega = System.Numerics.Vector3.Zero;
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 omegaToApply =
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
if (omegaToApply.LengthSquared() > 1e-9f)
{
float angleDelta = omegaToApply.Length() * (float)dt;
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
}
}
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
{
float omegaMag = rm.ObservedOmega.Length();
var axis = rm.ObservedOmega / omegaMag;
float angle = omegaMag * dt;
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
}
// Step 3: integrate physics — retail FUN_005111D0
// UpdatePhysicsInternal. Pure Euler:
// position += velocity × dt + 0.5 × accel × dt²
//
// Call UpdatePhysicsInternal DIRECTLY rather than via
// PhysicsBody.update_object (FUN_00515020). update_object gates
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
// deltaTime < MinQuantum → early return AND LastUpdateTime is
// NOT advanced. Net effect: position never integrates between
// UpdatePositions and the only Body.Position changes come
// from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported).
//
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
// directly for the same reason. Remote motion mirrors that.
// Omega is already integrated manually above, so we zero it
// here to prevent UpdatePhysicsInternal's own omega pass from
// double-integrating.
var preIntegratePos = rm.Body.Position;
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
// sweep so collision resolves whichever movement won (preIntegratePos
// captured first — the sweep covers it).
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
// the movement source is the adjust_offset walk toward the
// MoveOrTeleport-queued server waypoint, exactly like Path A
// (:10173). StickyManager::adjust_offset then OVERWRITES the
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
// dichotomy), so a stuck monster still steers via #171.
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
// from velocity + gravity, unchanged).
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
// adds no translation on top of the catch-up — no double-move.
if (rm.Host is { } npcHost)
{
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
if (!rm.Airborne)
{
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
{
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation, offsetNpc),
};
}
else
{
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
}
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
else if (!rm.Airborne)
{
// No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202).
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
rm.Body.Position += rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
}
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
var postIntegratePos = rm.Body.Position;
// Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition.
// Projects the sphere from preIntegratePos to postIntegratePos
// through the BSP + terrain, resolving:
// - terrain Z snap along the slope (fixes the "staircase" where
// horizontal Euler motion up a slope sinks into rising ground
// until the next UP pops it up)
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
// - object collisions via ShadowObjectRegistry
// - step-up / step-down against walkable ledges
// ResolveWithTransition is the same call PlayerMovementController
// uses for the local player; remotes now get the full retail
// treatment between UpdatePositions instead of pure kinematics.
//
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
// a SpherePath without a starting cell). One-frame grace until
// the first UP arrives; harmless because the entity is
// server-freshly-spawned at a valid Z anyway.
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
{
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
// creatures de-overlap at their TRUE radii (a big monster
// spreads wider, a small one tighter), not the hardcoded
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
// setup.Height) × ObjScale — the creature's own dat Setup
// scaled by its wire ObjScale, the same source the local
// player + moveto/sticky use, and consistent with the
// spawn-time shadow registration's entScale. Retail seeds
// the transition from the object's own Setup sphere list ×
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
// ObjScale from set_description 0x00514f40). This narrows
// TS-46 (remotes no longer use human dims); the two-scalar
// API is still a lossy stand-in for retail's full (≤2)
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
// those from the Setup too — an adjacent divergence left as-is).
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
sphereHeight: deH,
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
// branch zeroes the +Z offset every step (same bug
// we hit on the local jump).
isOnGround: !rm.Airborne,
body: rm.Body, // persist ContactPlane across frames for slope tracking
// Retail default physics state includes EdgeSlide; remote DR
// should exercise the same edge/cliff branch as local movement.
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
// CollisionExemption's PvP block fires exactly as it does for the
// LOCAL player (PlayerMovementController :920) — two non-PK players
// WALK THROUGH each other (retail sets IsPlayer on every object's
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
// non-PK player pair). Without IsPlayer the mover would de-overlap
// two players — MORE solid than retail (you can stand inside another
// non-PK player in AC). Players still COLLIDE with monsters (target
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
// pair walks through where retail collides — the SAME M1.5 gap the
// local player carries (see TS-23; PlayerDescription PK status
// unparsed).
moverFlags: IsPlayerGuid(serverGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
// Fix #42 (2026-05-05): skip the moving remote's
// own ShadowEntry. _animatedEntities is keyed by
// entity.Id so kv.Key matches the EntityId the
// ShadowObjectRegistry has for this remote.
// Without this, the airborne sweep collides with
// the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces).
movingEntityId: ae.Entity.Id);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
// re-registers a moved object's shadow every transition step
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
// 0x00515330) so its m_position — the RESOLVED position — is what
// OTHER creatures collide against. acdream's shadow otherwise only
// syncs to the RAW server pos on UpdatePosition, so neighbours would
// de-overlap against each other's OVERLAPPING shadows and any spread
// would be discarded on the next UP (never accumulating), AND the
// player would collide with a shadow offset from where the monster
// renders (the reverted attempt's "stuck on an invisible monster").
// Syncing the shadow to the resolved body every tick makes the
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm since the last shadow registration. This is
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
// players too — every remote's shadow (player + NPC) is written ONLY
// by this loop + the UP-branch tail, both to the resolved body, so a
// net-stationary (de-overlapped, sweep-
// blocked) creature keeps its correct shadow and never re-floods,
// while a moving/de-overlapping crowd (which moves every tick) still
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
// to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.)
if (System.Numerics.Vector3.DistanceSquared(
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
{
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
}
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
// is ordinary object physics in retail. acdream ported the
// velocity reflection for the LOCAL player only (L.3a,
// PlayerMovementController ~:940), so a remote jumping into
// a dungeon ceiling had its POSITION pinned by the sweep
// while its +Z velocity kept integrating — the char hovered
// at the roof until gravity burned the arc off, landing
// late (user report, 0x0007 dungeon). Mirror the local
// site exactly:
// v_new = v (1 + elasticity)·dot(v, n)·n
// with the AD-25 suppression (bounce only when airborne
// before AND after — corridor slides and landings don't
// reflect; the landing snap below keeps its
// `Velocity.Z <= 0` gate intact). Inelastic movers
// (missiles, later) zero out instead.
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = rm.Body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
bool applyBounce = rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
if (applyBounce)
{
if (rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var vRem = rm.Body.Velocity;
var nRem = resolveResult.CollisionNormal;
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
if (dotVN < 0f)
{
rm.Body.Velocity =
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine(
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
}
}
}
}
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing
// path: when the resolver says we're on ground AND
// velocity is no longer pointing up, transition
// back to grounded — clear Airborne, restore
// Contact + OnWalkable, remove Gravity, zero any
// residual downward velocity, and trigger
// HitGround so the sequencer can swap from
// Falling → idle/locomotion. Without this, an
// airborne remote falls through the floor (gravity
// keeps building Velocity.Z negative until the
// sphere-sweep clamps each frame, but Airborne
// stays true forever).
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
// #184 (2026-07-07): clear the interp queue on landing (mirrors
// the player-remote landing). Airborne UPs hard-snap and never
// Enqueue, so any pre-jump waypoints are stale; without this the
// first grounded catch-up after touchdown chases them backward.
rm.Interp.Clear();
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
// #161: HitGround MUST run with the Gravity state
// bit still set — CMotionInterp::HitGround
// (0x00528ac0) gates on state&0x400 (retail never
// clears GRAVITY on landing; it's a persistent
// object property). Clearing it first made this
// re-apply a silent no-op, which is why the
// falling pose never exited. The re-apply
// dispatches the PRESERVED pre-fall forward
// command through the funnel → the motion table
// plays the Falling→X landing link. (The old
// K-fix17 forced SetCycle is deleted: it read the
// then-clobbered InterpretedState.ForwardCommand
// — 0x40000015 — and re-set the very Falling
// cycle it meant to clear.)
// R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order —
// minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d — the R5-V5 facade
// relay). Re-arms a moveto suspended by the
// airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit.
rm.Movement.HitGround();
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body.
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
if (rm.CellId != 0)
ae.Entity.ParentCellId = rm.CellId;
ae.Entity.Rotation = rm.Body.Orientation;
}
// R5-V3 (#171): retail UpdateObjectInternal tail —
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
// right after CPartArray::HandleMovement, UNCONDITIONAL for
// every entity in both grounded and airborne branches): the
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
rm.Host?.PositionManager.UseTime();
}
/// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
/// </summary>
private static void TickRemoteMoveTo(RemoteMotion rm)
{
rm.Movement.UseTime();
}
/// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
/// out by the body orientation. An untouched (identity) rotation means "no
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
/// DR tick is its only caller.
/// </summary>
private static void ApplyPositionManagerDelta(
AcDream.Core.Physics.PhysicsBody body,
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
{
if (delta.Origin != System.Numerics.Vector3.Zero)
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
if (!delta.Orientation.IsIdentity)
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
body.Orientation,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
+ delta.GetHeading());
}
/// <summary>
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
/// de-overlap / collide against where the monster actually IS (== where it
/// renders), not the raw overlapping server position. Retail re-registers a
/// moved object's shadow every accepted transition step (SetPositionInternal
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
/// UP-branch tail.
/// </summary>
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
{
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
float shOffX = (shLbX - liveCenterX) * 192f;
float shOffY = (shLbY - liveCenterY) * 192f;
_physicsEngine.ShadowObjects.UpdatePosition(
entityId, rm.Body.Position, rm.Body.Orientation,
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
rm.LastShadowSyncPos = rm.Body.Position;
}
}

View file

@ -0,0 +1,149 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private readonly Func<Position> _getPosition;
private readonly Func<Vector3> _getVelocity;
private readonly Func<float> _getRadius;
private readonly Func<bool> _inContact;
private readonly Func<float?> _minterpMaxSpeed;
private readonly Func<double> _curTime;
private readonly Func<double> _physicsTimerTime;
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
private readonly Action<TargetInfo> _handleUpdateTarget;
private readonly Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
_getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
_getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
_inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
_minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
_curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
_physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
_getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
_handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
_interruptCurrentMovement = interruptCurrentMovement
?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> _targetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

File diff suppressed because it is too large Load diff

View file

@ -35,8 +35,55 @@ public static class InteriorEntityPartition
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> OutdoorStatic { get; } = new();
public List<WorldEntity> Dynamics { get; } = new();
// MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames
// so pruning itself doesn't allocate.
private readonly List<uint> _emptyCellScratch = new();
/// <summary>
/// MP-Alloc (2026-07-05): clear every collection in place for reuse
/// by <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>.
/// The per-cell lists inside <see cref="ByCell"/> are cleared and
/// KEPT (not removed) so a steady-state frame with the same visible
/// cell set reuses the same List&lt;WorldEntity&gt; instances instead
/// of reallocating one per cell every frame.
/// </summary>
internal void ClearForReuse()
{
foreach (var list in ByCell.Values)
list.Clear();
OutdoorStatic.Clear();
Dynamics.Clear();
}
/// <summary>
/// MP-Alloc: drop any cell bucket that ended this frame with zero
/// entries (either newly emptied, or a leftover key from a previous
/// frame's visible-cell set that this frame never touched). Keeps
/// ByCell.Count / .Keys bit-identical to the old always-fresh-
/// Dictionary behavior — callers that inspect key presence/count
/// directly (not just TryGetValue) must see exactly the cells that
/// actually received at least one static this frame.
/// </summary>
internal void PruneEmptyCellBuckets()
{
_emptyCellScratch.Clear();
foreach (var (cellId, list) in ByCell)
{
if (list.Count == 0)
_emptyCellScratch.Add(cellId);
}
foreach (var cellId in _emptyCellScratch)
ByCell.Remove(cellId);
}
}
/// <summary>
/// Allocating overload — always returns a brand-new <see cref="Result"/>.
/// Kept for tests and any one-shot caller; the per-frame render path
/// uses the <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>
/// reuse overload instead (see <see cref="RetailPViewRenderer"/>).
/// </summary>
public static Result Partition(
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
@ -44,6 +91,28 @@ public static class InteriorEntityPartition
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
{
var result = new Result();
Partition(result, visibleCells, landblockEntries);
return result;
}
/// <summary>
/// MP-Alloc (2026-07-05): reuse overload. Clears <paramref name="result"/>
/// in place (see <see cref="Result.ClearForReuse"/>) and refills it,
/// reusing each cell's existing <c>List&lt;WorldEntity&gt;</c> when the
/// cell key survives from the previous frame instead of allocating a new
/// one — the per-cell dictionary entries persist across frames (cleared,
/// never removed) since the visible-cell set is usually stable frame to
/// frame. Identical partitioning output to the allocating overload; only
/// the backing storage is reused.
/// </summary>
public static void Partition(
Result result,
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
foreach (var e in entry.Entities)
@ -70,7 +139,8 @@ public static class InteriorEntityPartition
}
}
}
return result;
result.PruneEmptyCellBuckets();
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the

View file

@ -48,6 +48,19 @@ public sealed unsafe class ParticleRenderer : IDisposable
private float[] _instanceScratch = new float[256 * 16];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
// each previously `new`ing a List<ParticleDraw> (BuildDrawList) and a
// List<ParticleInstance> (the per-batch `run` list) that became garbage
// as soon as the call returned. All Draw() calls happen sequentially on
// the render thread (verified: every call site in GameWindow.cs is a
// plain synchronous invocation from the single-threaded OnRender chain,
// none dispatched via Task.Run/Parallel) and each call fully drains its
// lists before returning, so a single pair of reused fields is safe -
// no call overlaps another's use of these buffers.
private readonly List<ParticleDraw> _drawListScratch = new(64);
private readonly List<ParticleInstance> _runScratch = new(64);
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
@ -133,6 +146,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
// draws IS _drawListScratch (see BuildDrawList) - sorting it in place
// is fine, nothing else reads it between BuildDrawList's return and
// this call.
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
@ -144,7 +160,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.Disable(EnableCap.CullFace);
_gl.ActiveTexture(TextureUnit.Texture0);
var run = new List<ParticleInstance>(64);
var run = _runScratch;
for (int i = 0; i < draws.Count;)
{
var key = draws[i].Key;
@ -178,7 +194,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
{
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
var draws = _drawListScratch;
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
{
if (em.RenderPass != renderPass)

View file

@ -180,10 +180,13 @@ public static class RenderBootstrap
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache();
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
var drawDispatcher = new Wb.WbDrawDispatcher(
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
bindless, classificationCache);
bindless, classificationCache, translucencyFades);
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
// --- Vitals dat font (GameWindow ~1820-1822) ---

View file

@ -8,8 +8,12 @@ namespace AcDream.App.Rendering;
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
/// a STATEFUL sought position that converges from the current swept
/// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
/// filter. Pseudocode:
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ────────────────────────────────────────────────
// ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position — the persisted sweep
// TARGET, re-derived each frame from the current swept
// viewer (NOT from itself).
// _publishedEye = retail viewer — the swept, published eye; the base
// of next frame's interpolation (SmartBox::
// PlayerPhysicsUpdatedCallback passes &this->viewer
// into UpdateCamera, 0x00452d75).
// _dampedForward = the sought's look direction. Sweeps translate but
// never rotate, so the viewer's rotation is always the
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount;
private Vector3 _dampedEye;
private Vector3 _soughtEye;
private Vector3 _publishedEye;
private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised;
@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
// 5. Exponential damping (independent translation + rotation rates).
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
// desired pose and assigns the result to viewer_sought_position
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
// target converges onto whatever the collision produced last frame
// and re-extends gradually. The full-length ideal boom is never swept
// directly. Pseudocode:
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
if (!_initialised)
{
_dampedEye = targetEye;
_soughtEye = targetEye;
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
_dampedForward = targetForward;
_initialised = true;
}
@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// VIEWER (0x00456fae), not the previous sought. The forward base is the
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
(_dampedEye, _dampedForward, _) =
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
// Retail UpdateCamera dead-band (0x00456fcd0x00457035): once the step
// off the viewer is sub-epsilon in translation AND rotation, the sought
// parks EXACTLY ON the viewer — an exact fixed point instead of an
// asymptote. Kills the at-rest drift AND the residual micro-jitter when
// pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
(_soughtEye, _dampedForward, _) =
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
// (0x00453ce0) keeps TWO states: viewer_sought_position (the damped
// desired eye) and viewer (the published eye = set_viewer(curr_pos)).
// The collision produces the PUBLISHED eye each frame but must NOT
// feed back into the damped state — writing the clamped result into
// _dampedEye makes next frame's lerp start from the wall and fight
// the clamp, which shows up as visible oscillation/vibration when the
// eye is pressed against a wall. So collide into a separate local and
// leave _dampedEye as the clean, uncollided sought position.
Vector3 publishedEye = _dampedEye;
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
// and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
// — the sought is NOT reset on success). Pressed against a wall, the
// sweep ray extends only one interpolation step past the contact, so a
// knife-edge r±ε graze can move the eye by at most that step (sub-mm at
// high fps) instead of re-solving the full-length boom with its 0.27 m
// bistable contact pair — the #180 strobe fix.
Vector3 publishedEye = _soughtEye;
// The viewer cell defaults to the player cell (collision off / null probe); the sweep
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
// robust per-frame "which cell is the camera in?" answer.
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
// ViewerCellId == 0): the sought resets to the returned position and
// re-extends from there.
if (swept.ViewerCellId == 0)
_soughtEye = swept.Eye;
}
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot).
@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the snap test),
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
/// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
/// is below <see cref="SnapEpsilon"/> AND the rotation step is below
/// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
/// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
/// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
return (candidateEye, candidateForward, false);
}

View file

@ -42,6 +42,21 @@ public sealed class RetailPViewRenderer
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
// place and reuses each cell's list across frames when the cell stays
// visible.
private readonly InteriorEntityPartition.Result _partitionResult = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every consumer (DrawEntityBucket, DrawExitPortalMasks,
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -110,7 +125,9 @@ public sealed class RetailPViewRenderer
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
_drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch;
UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
@ -129,6 +146,11 @@ public sealed class RetailPViewRenderer
prepareCells = _lookInPrepareScratch;
}
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
// is built once per frame in GameWindow, player-anchored.)
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
@ -137,7 +159,8 @@ public sealed class RetailPViewRenderer
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,

Some files were not shown because too many files have changed in this diff Show more