diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index c21f9cf1..7d713970 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -44,6 +44,78 @@ Copy this block when adding a new issue:
---
+## #220 — Local player glides before locomotion animation starts
+
+**Status:** DONE 2026-07-17 — user-confirmed connected visual gate
+**Severity:** HIGH
+**Filed:** 2026-07-17
+**Component:** local movement / animation / physics
+
+**Description:** A very short W tap moved the acdream character before the
+Ready→Walk/Run animation visibly started, producing a brief foot-slide.
+
+**Root cause / status:** The grounded local controller called
+`CMotionInterp::get_state_velocity` and installed the full 3.12–4.0 m/s body
+velocity on the input edge. Retail instead advances `CPartArray` first and
+uses the exact root displacement authored by the current transition/cycle;
+`PositionManager::adjust_offset` then composes into that same Frame before the
+collision sweep. The local player now advances its owning `AnimationSequencer`
+after input dispatch, accumulates the emitted root displacement to the 30 Hz
+physics quantum, and passes it through the existing PositionManager/collision
+path. The prepared part pose is reused by the presentation pass so animation
+time and hooks advance exactly once.
+
+**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`;
+`src/AcDream.App/Rendering/GameWindow.cs`;
+`tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`;
+`tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs`.
+
+**Research:**
+`docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md`.
+
+**Acceptance:** From rest, tap W briefly several times. The body must not move
+before the Ready→Walk/Run transition produces movement. Holding W must still
+reach normal continuous speed; release, backward/strafe, and jump remain
+correct.
+Passed 2026-07-17: “Good! works.”
+
+---
+
+## #219 — Outbound movement dropped the player's combat stance
+
+**Status:** DONE 2026-07-17 — user-confirmed from a retail observer
+**Severity:** HIGH
+**Filed:** 2026-07-17
+**Component:** movement wire / combat stance / remote animation
+
+**Description:** acdream looked correct locally while moving in combat mode,
+but a retail observer saw the character leave combat stance and locomote in
+NonCombat.
+
+**Root cause / status:** Retail `CommandInterpreter::SendMovementEvent` passes
+the canonical `MovementManager::RawMotionState` directly into
+`MoveToStatePack`. acdream instead projected the raw axes through
+`MovementResult` and rebuilt a packet state without `CurrentStyle`, leaving its
+retail default `0x8000003D` NonCombat. `RawMotionStatePacker` therefore omitted
+the style bit; ACE relayed no combat stance, and retail's unpack correctly
+defaulted the observer to NonCombat. `MovementResult` now carries the canonical
+`MotionInterpreter.RawState.CurrentStyle`, and the outbound builder writes it
+without inferring from CombatMode or equipment.
+
+**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`;
+`src/AcDream.App/Input/LocalPlayerOutboundController.cs`;
+`tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs`.
+
+**Research:**
+`docs/research/2026-07-17-combat-movement-outbound-style-pseudocode.md`.
+
+**Acceptance:** With a melee, bow/crossbow, or magic stance active, move the
+acdream player while observing from retail. The observer retains the same
+combat stance and uses its combat locomotion instead of switching to peace.
+Passed 2026-07-17: “Yes works.”
+
+---
+
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate
@@ -93,7 +165,10 @@ removed by owner before prefix cleanup, including footprints flooded across a
landblock seam, while server-live/dynamic registrations remain refloodable.
Full unload uses the same owner rule; surviving owners seeded across the seam
track withdrawn prefixes so a later reload restores their missing collision
-rows.
+rows. Destination reveal now also keeps sky and landscape on the same active
+SmartBox projection, matching `SmartBox::RenderNormalMode`/`GameSky::Draw`;
+the old fixed 60-degree sky camera exposed the clear/fog background during the
+nearly 180-degree exit warp.
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`;
@@ -101,6 +176,7 @@ rows.
`src/AcDream.App/Streaming/GpuWorldState.cs`;
`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`;
`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`;
+`src/AcDream.App/Rendering/Sky/SkyProjection.cs`;
`src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs`;
`src/AcDream.Core/Physics/PhysicsBody.cs`;
`src/AcDream.App/Input/PlayerMovementController.cs`.
@@ -4802,85 +4878,49 @@ the local terrain normal, not the actor's facing.
## #41 — Residual sub-decimeter blips on observed player remotes (M3 baseline)
-**Status:** OPEN
-**Severity:** LOW (within retail's own DesiredDistance / MinDistance tolerances; visible only on close inspection)
+**Status:** FIX IMPLEMENTED 2026-07-17 — pending acdream-observer visual gate
+**Severity:** MEDIUM
**Filed:** 2026-05-05
**Component:** physics / motion / animation (per-tick remote prediction)
-**Phase:** L.2 (Movement & Collision Conformance) — inbound-motion fidelity sub-piece. Blocked on cdb-trace of `CSequence::velocity` for Humanoid running cycle, then porting `add_motion @ 0x005224b0`'s `style_speed × MotionData.velocity` chain.
+**Phase:** L.2 (Movement & Collision Conformance) — inbound-motion fidelity sub-piece.
-**Description:** With the L.3 M3 path live (queue catch-up + animation
-root motion fallback), observed player remotes chase server position
-smoothly with NO staircase on slopes and NO per-UP rubber-band. However
-small position blips remain — sub-decimeter amplitude, periodic with
-the server's UP cadence (~1 Hz). User report 2026-05-05: "I get very
-small blips now. Running works, walking works, strafing works."
+**Description:** Observed characters walked forward, blipped backward, then
+continued, periodic with the server's UpdatePosition cadence. The earlier
+2026-05-05 report called the residue sub-decimeter; on 2026-07-17 the user
+reported the same mechanism as plainly visible repeated rollback.
-The blips fall well within retail's own tolerances:
+**Root cause / fix:** Retail `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`
+advances `CPartArray::Update` into a complete local root-motion Frame, then
+lets `PositionManager::adjust_offset` replace that Frame with interpolation
+catch-up. `add_motion @ 0x005224B0` writes only literal
+`MotionData.Velocity × speed` into CSequence. acdream instead synthesized
+Walk/Run constants into `AnimationSequencer.CurrentVelocity` and reconstructed
+a delta after the queue emptied. The installed Humanoid MotionTable proves
+both Walk and Run carry zero MotionData velocity, so acdream ran past the last
+server waypoint; the next UpdatePosition pulled it backward.
-- `DesiredDistance` (queue head reach radius) = 0.05 m
-- `MinDistanceToReachPosition` (primary stall threshold) = 0.20 m
-
-So they are NOT a stall trigger and NOT a correctness bug. They're a
-visible artifact of the velocity-synthesis residual: anim root motion
-(`AnimationSequencer.CurrentVelocity = RunAnimSpeed × adjustedSpeed`)
-slightly overshoots server pace between UPs, then queue catch-up walks
-the body back toward the server position on the next UP — a small
-rubber-band that's smaller than M2's pre-fix version but still
-perceptible.
-
-**Root cause hypothesis (untested):**
-
-The L.3 handoff explicitly flagged this. From `06-acdream-audit.md` § 9
-and `05-position-manager-and-partarray.md` § 7:
-
-> Our `CurrentVelocity` carries only the steady-state component of the
-> cycle's intent; the per-frame stride wobble is gone… For Humanoid
-> the dat ships `MotionData.Velocity = 0` so the multiply is a no-op
-> anyway — but the synth uses `RunAnimSpeed × adjustedSpeed` directly.
-
-ACE's wire `ForwardSpeed` for a running player is the **server runRate**
-(~2.94 for skill 200), not a unit multiplier. Our synth multiplies
-`RunAnimSpeed` (4.0) by `adjustedSpeed` (~2.94) = ~11.76 m/s, which
-the queue catch-up clamps via `min(catchUp × dt, dist)` but the anim
-fallback applies in full when the queue is idle. If the actual
-server-broadcast pace is closer to 4.0 m/s (RunAnimSpeed alone, with
-runRate as a *frame-rate* multiplier rather than a velocity scalar),
-our fallback overshoots by ~3× and the queue walks it back every UP.
-
-Per the handoff: **don't normalize at the wire boundary** (prior
-session tried this, called it a hack). The right fix is porting
-retail's actual behavior in `add_motion @ 0x005224b0` and
-`apply_run_to_command` to determine the correct `CSequence::velocity`
-magnitude.
+Remote animation now advances before remote physics into the already-ported
+CSequence root-motion Frame. `RemotePhysicsUpdater` consumes that exact local
+delta, and `RemoteMotionCombiner` applies the retail interpolation-replaces-
+root-motion rule. Command-derived locomotion synthesis was removed from
+CSequence; the local interpreted body path retains retail
+`CMotionInterp::get_state_velocity` through its existing zero-data fallback.
**Files:**
-- `src/AcDream.Core/Physics/AnimationSequencer.cs` — `CurrentVelocity`
- synthesis at L614–679 (RunAnimSpeed=4.0, WalkAnimSpeed=3.12,
- SidestepAnimSpeed=1.25 × adjustedSpeed)
-- `src/AcDream.Core/Physics/PositionManager.cs` — `ComputeOffset`
- applies `seqVel × dt × orientation` as fallback when queue is idle
+- `src/AcDream.Core/Physics/AnimationSequencer.cs`
+- `src/AcDream.Core/Physics/RemoteMotionCombiner.cs`
+- `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`
+- `src/AcDream.App/Rendering/GameWindow.cs`
+- `tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs`
**Research:**
-- `docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md` § 5–7
-- `docs/research/2026-05-04-l3-port/06-acdream-audit.md` § 9 (AnimationSequencer)
-- `docs/research/named-retail/acclient_2013_pseudo_c.txt` line 298437
- (`add_motion @ 0x005224b0`) — `CSequence::velocity = style_speed × MotionData.velocity`
-
-**Fix path (research first, then port):**
-
-1. cdb-trace retail to capture `CSequence::velocity` and
- `MotionData::velocity` for a Humanoid running cycle. Compare against
- our synth (4.0 × 2.94 = 11.76 m/s) to determine the actual retail
- magnitude.
-2. Port `add_motion`'s `style_speed × MotionData.velocity` chain
- verbatim. For Humanoid where `MotionData.Velocity = 0`, port the
- fallback retail uses (likely a separate code path through
- `apply_run_to_command` that derives velocity from the cycle's
- framerate, not a constant).
-3. Remove the `RunAnimSpeed × adjustedSpeed` synth in
- `AnimationSequencer.SetCycle`.
+- `docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.md`
+- `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`
+- named retail `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`,
+ `CPartArray::Update @ 0x00517DB0`, `add_motion @ 0x005224B0`, and
+ `InterpolationManager::adjust_offset @ 0x00555D30`
**Acceptance:**
@@ -4888,8 +4928,6 @@ magnitude.
- Side-by-side acdream-as-observer vs retail-as-observer of the same
server-controlled toon: indistinguishable body trajectory.
-**2026-07-09 note (user):** "semi fixed, needs polish" — stays open, downgrade to polish-tier if a fresher look confirms the core bug is gone.
-
---
## #40 — [DONE 2026-05-05 · 40d88b9] ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips)
@@ -7264,7 +7302,7 @@ The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) inst
## #113 — Phantom staircase / holistic building-render port
**Status:** DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
-The Holtburg meeting-hall's walkable stair-ramp painted across the exterior wall because the PView shell-clip pass computed correct clip regions but never enabled `GL_CLIP_DISTANCE` — fixed by `927fd8f`. The bug reopened 2026-06-11 when a second root cause surfaced (dictionary-referenced-but-undrawn BSP polys drawn that retail never draws); per the user's mandate this was folded into the holistic building-render port effort (#113/#114/#108/#109/#99, charter at `docs/research/2026-06-11-building-render-holistic-port-handoff.md`), which shipped as part of the broader render-pipeline redesign (Option A) closed 2026-06-12.
+The Holtburg meeting-hall's walkable stair-ramp painted across the exterior wall because the PView shell-clip pass computed correct clip regions but never enabled `GL_CLIP_DISTANCE` — fixed by `927fd8f`. The bug reopened 2026-06-11 when a suspected second root cause surfaced (dictionary-referenced-but-undrawn BSP polys); per the user's mandate this was folded into the holistic building-render port effort (#113/#114/#108/#109/#99, charter at `docs/research/2026-06-11-building-render-holistic-port-handoff.md`), which shipped as part of the broader render-pipeline redesign (Option A) closed 2026-06-12. A 2026-07-16 named-retail audit later disproved the proposed global DrawingBSP polygon filter: `CGfxObj::InitLoad`/`D3DPolyRender::ConstructMesh` consume the complete polygon array, and the alleged shell orphans are portal references omitted by the old diagnostic collector. See retired divergence TS-20; do not retry `e46d3d9`'s filter, which made doors disappear.
## #111 — ACE-mutated indoor restores: transparent interior / wrong placement at login — [DONE 2026-06-10 · 5f1eb7c + 5706e0e + 2735695]
@@ -7841,18 +7879,18 @@ formula.
**Resolution:** Grounded player remotes showed a ~5 Hz Z staircase when
running up/down hills. `PositionManager.ComputeOffset` has two modes:
queue-active (3D direction toward server's broadcast position, Z
-follows naturally) and queue-empty / head-reached (`seqVel × dt`
-rotated into world). Every locomotion cycle bakes Z=0 in body-local,
+follows naturally) and queue-empty / head-reached (the CSequence
+root-motion delta rotated into world). Every locomotion cycle bakes Z=0 in body-local,
so the world result has Z=0 too. With server UPs at ~5 Hz and
catchUpSpeed = 2× maxSpeed, body chases each waypoint in ~100ms (Z
-ramps), then sits in seqVel-only mode for ~100ms (Z flat) until the
+ramps), then sits in root-motion-only mode for ~100ms (Z flat) until the
next UP. Visible 5 Hz staircase.
Fix mirrors retail's `CTransition::adjust_offset` contact-plane
projection (named-retail acclient_2013_pseudo_c.txt:272296-272346),
applied at the queue-empty boundary instead of inside the sweep.
`ComputeOffset` gains an optional `Vector3? terrainNormal`; when
-the seqVel fallback runs and the supplied normal is non-trivial,
+the root-motion fallback runs and the supplied normal is non-trivial,
`rootMotionWorld -= N × dot(rootMotionWorld, N)`. XY motion gains a
Z component proportional to slope × forward speed; body Z follows the
terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0) so
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 11bbbba7..cfc0a648 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -43,7 +43,7 @@ accepted-divergence entries (#96, #49, #50).
|---|---|---|---|---|---|
| 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-3 | **NARROWED 2026-07-17 — `get_state_velocity` may prefer a nonzero dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant.** Production grounded player/remote translation no longer consumes this value; both use the literal CSequence root Frame. The accessor remains observable for jump launch and headless/test fallbacks | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`get_state_velocity`); grounded owners in `PlayerMovementController` / `RemotePhysicsUpdater` | Installed Humanoid Walk/Run MotionData velocity is zero, so the retail constants remain the jump/fallback result. A nonzero exotic/modded cycle may override them, preserving the earlier adapter contract without affecting ordinary grounded motion | Jump horizontal speed for an exotic MotionTable with nonzero authored velocity can differ from the retail binary's constants | `CMotionInterp::get_state_velocity` 0x00527D50; `CPhysicsObj::UpdatePositionInternal` 0x00512C30 |
| 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-8 | Synthetic outdoor cell node as render root (outdoor-as-cell, Option A): one unified `DrawInside` path; retail roots at a real CLandCell with a separate outdoor pipeline | `src/AcDream.App/Rendering/OutdoorCellNode.cs:23` | Eliminating the inside/outside render branch kills the indoor FLAP by construction (2026-06-07 cutover); R-A2 restored retail's per-building flood topology | Any consumer assuming the root is a real cell mis-handles the synthetic node — historically the 2↔6 flood-depth oscillation and doorway-flap class | `SmartBox::RenderNormalMode` → DrawInside, decomp:92635; `LScape::draw` 0x00506330; ConstructView(CBldPortal) decomp:433827 |
@@ -61,10 +61,11 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
+## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
+| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. Login placement keeps #135's split: a hydratable indoor claim gates on its EnvCell floor (`IsSpawnCellReady`) rather than meaningless dungeon terrain; outdoor placement gates on terrain. **#218 refinement (2026-07-16):** F751 portal-space exit joins BOTH publication domains in `TeleportWorldReady`: indoor requires the center landblock's Near-tier static/EnvCell mesh set uploaded plus the EnvCell in physics, while outdoor requires that Near-tier render readiness plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Rendering/GameWindow.cs` (`TeleportWorldReady` + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail completing its blocking cell load before `EndTeleportAnimation`: neither an empty collision world, a terrain-only Far shell, nor a published-but-not-drawable GPU landblock may be revealed. Indoor does not require a terrain heightmap, only the owning render landblock and the exact EnvCell. | Gate opens early → grey/untextured reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → portal transit reaches its existing loud timeout/forced-placement diagnostic instead of silently hanging forever. | retail synchronous cell load before SetPosition / `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
@@ -176,9 +177,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
---
-| 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-75 | **NARROWED 2026-07-17 — adapter-boundary `adjust_motion` + turn-omega synthesis only.** The locomotion-velocity half is RETIRED: CSequence now retains literal `MotionData.Velocity × speed`, and remote translation consumes the complete Frame emitted by `CSequence::update` before interpolation. Remaining: `SetCycle` (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp`; GameWindow's local-player path can still pass raw ids), and (b) synthesizes ±π/2×speed omega for dat-silent TurnRight/TurnLeft after dispatch | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + turn-omega tail); retired velocity consumer in `RemotePhysicsUpdater`/`RemoteMotionCombiner` | (a) preserves unadjusted local callers until they all enter through MotionInterpreter; (b) preserves remote rotation until the literal CSequence orientation delta replaces the ObservedOmega side channel (AP-76) | A DAT with authored turn omega different from π/2 is overridden only when the table is silent; boundary remapping can become a double-adjust if a future caller already normalizes a raw left/back command but still passes its original id | `CMotionInterp::adjust_motion` @305343; `add_motion` 0x005224B0; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire with the remaining R6 rotation/caller unification |
| 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-77 | **NARROWED 2026-07-17 — null-sink/headless `apply_current_movement` fallback only.** When `MotionInterpreter.DefaultSink` is absent, `ApplyCurrentMovementInterpreted` writes grounded body velocity directly via `get_state_velocity`/`set_local_velocity` instead of dispatching through the animation-table backend. Every production animated player and remote binds `MotionTableDispatchSink`; the local controller also consumes literal sequencer root displacement, so this tail is not the live humanoid locomotion owner | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Keeps MotionInterpreter usable in isolated physics tests and for an entity with no animation runtime; production animation owners take the retail sink path | A future production entity instantiated without a DefaultSink silently falls back to command-derived body velocity and can foot-slide or drift from its animation | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when animation-less production objects have an explicit motion owner |
| 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. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); 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) |
@@ -188,7 +189,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.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 `|Body−LastShadowSyncPos| > 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-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` used to rotate CSequence's literal root-motion delta when the interpolation queue is empty/head-reached is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while 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) |
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
@@ -214,7 +215,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
-## 4. Temporary stopgap (TS) — 34 active 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)
+## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; 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 |
|---|---|---|---|---|---|
@@ -231,7 +232,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 |
| 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-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16** — `CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons ∪ portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` |
| 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 11–15) + 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 |
@@ -281,16 +282,15 @@ likelihood the guarding assumption breaks). Items below the line are
phase-gated — they carry their trigger in their row and should land
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-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.
-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. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
-11. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
+1. **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.
+2. **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.
+3. **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).
+4. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
+5. **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.
+6. **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.
+7. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
+8. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
+9. **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-5 (CanJump gating), TS-23 (PK bits), TS-25
diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
index a3614471..b3e557e2 100644
--- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md
+++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md
@@ -232,6 +232,100 @@ destination world at this projection, then `WorldFadeIn` expands the world
from the center as the captured game projection is restored. This is retail's
characteristic destination-world warp, independent of chase-camera movement.
+`CreatureMode::Render @ 0x004529D0` changes the portal viewport's FOV through
+the SmartBox path but does not install a private far clip. The resulting
+`PrimD3DRender::SetFOVInternal @ 0x0059AB40` projection uses the shared
+`Render::zfar` (default `4000` at `0x0081EC88`). The portal CreatureMode must
+therefore inherit the active SmartBox projection's near and far planes rather
+than installing a private short clip range.
+
+### Portal viewport buffer lifecycle
+
+The portal tunnel is rendered by `UIViewportObject::DrawContent @ 0x00695030`.
+Before calling `CreatureMode::Render`, it executes:
+
+```text
+SetViewport(portal bounds)
+RenderDevice.Clear(flags = 4, color = black, depth = 1.0)
+CreatureMode.Render()
+restore previous viewport and matrices
+```
+
+`RenderDeviceD3D::Clear @ 0x0059FD30` maps retail clear flags explicitly:
+
+```text
+flag 1 -> D3DCLEAR_TARGET
+flag 2 -> D3DCLEAR_STENCIL, when a stencil buffer exists
+flag 4 -> D3DCLEAR_ZBUFFER
+```
+
+Therefore the portal viewport clears **depth only**. The black color argument
+is not used for flag 4. This does not preserve an earlier swap-chain image:
+`Client::UseTime @ 0x00411C40` begins the complete frame through
+`SceneTool::BeginScene @ 0x0043DAD0`, whose `RenderDevice.Clear(flags=7,
+black, depth=1)` clears color, depth, and stencil. The later viewport clear
+retains that frame's fresh black color target while resetting depth for the
+portal CreatureMode.
+
+`gmSmartBoxUI::UseTime @ 0x004D6E30` swaps visibility between the normal
+SmartBox and the portal `UIElement_Viewport`. The world viewport is therefore
+not redrawn behind portal space; the portal mesh is drawn over the whole-frame
+black target, never over the destination world or stale tunnel history.
+
+The faithful presentation rule is:
+
+```text
+at the beginning of every frame:
+ clear color, depth, and stencil to black
+while the portal viewport is visible:
+ do not draw the normal world viewport
+ clear depth to 1.0
+ draw portal CreatureMode over this frame's black color target
+```
+
+One high-refresh presentation edge remains. `UIGlobals::GetAnimLevel` yields
+1022 at table index 96, 1023 at index 97, and 1024 at indices 98 and 99. The
+2013 client capture presents 1022 as the last outgoing viewport sample and
+does not present the finite tunnel at 1023/1024 before switching viewports.
+With the world pass suppressed, acdream runs portal space at roughly 2000 FPS
+and can publish those sub-20.2 ms samples; the desktop compositor may then
+hold one for a complete monitor refresh and expose the finite tunnel boundary.
+
+The frame-rate-independent presentation adaptation is therefore:
+
+```text
+if state is WorldFadeOut or TunnelFadeOut
+and GetAnimLevel(elapsed / FadeTime) > 1022:
+ perform the normal state transition before publishing the draw snapshot
+```
+
+This advances only the outgoing presentation edge by at most two table quanta
+(about 20.2 ms) and is recorded as AD-38. Incoming fades retain the literal
+timer.
+
+### Shared sky and landscape projection during destination reveal
+
+`SmartBox::RenderNormalMode @ 0x00453AA0` installs the active SmartBox
+view-plane/FOV through `Render::set_vdst` or `Render::SetFOVRad` before calling
+`LScape::draw @ 0x00506330`. `GameSky::Draw @ 0x00506FF0` then saves
+`Render::zfar`, sets it to four times the active value for the sky draw, and
+restores it. It does **not** install an independent sky FOV.
+
+The destination world is first revealed at a nearly 180-degree projection.
+Sky and landscape must therefore preserve the same horizontal/vertical
+projection scales and off-center terms. Only the sky's near/far depth mapping
+may differ. acdream's former fixed 60-degree sky projection left part of the
+frame covered only by the clear/fog background while terrain used the teleport
+projection; this was the brief background flash at tunnel exit.
+
+```text
+activeProjection = SmartBox view-plane projection
+skyProjection = activeProjection
+skyProjection.depthRange = authored sky near/far range
+draw sky
+draw landscape with activeProjection
+```
+
Instruction-level checks that disambiguate the Binary Ninja x87 output:
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`: when no override is active,
diff --git a/docs/research/2026-07-17-combat-movement-outbound-style-pseudocode.md b/docs/research/2026-07-17-combat-movement-outbound-style-pseudocode.md
new file mode 100644
index 00000000..a169b63c
--- /dev/null
+++ b/docs/research/2026-07-17-combat-movement-outbound-style-pseudocode.md
@@ -0,0 +1,84 @@
+# Retail combat-style ownership in outbound movement
+
+## Symptom
+
+The local acdream player remains visually in combat while moving, but a retail
+observer sees the player leave the combat stance and locomote in NonCombat.
+
+## Named-retail oracle
+
+`CommandInterpreter::SendMovementEvent @ 0x006B4680` does not construct a new
+axis-only motion object. It obtains the player's canonical raw state and passes
+that exact object to the packet constructor:
+
+```text
+player = CommandInterpreter.player
+raw = player.InqRawMotionState()
+if player != null and smartbox != null and raw != null and autonomy_level != 0:
+ packet = MoveToStatePack(
+ raw,
+ player.position,
+ player is Contact+OnWalkable,
+ player.minterp.standing_longjump,
+ player movement timestamps)
+ SendMoveToStateEvent(packet)
+```
+
+`CPhysicsObj::InqRawMotionState @ 0x0050FDE0` returns
+`MovementManager::InqRawMotionState`; stance and movement axes therefore have
+one canonical owner.
+
+`RawMotionState::Pack @ 0x0051ED10` compares `current_style` with the retail
+default `MotionStance_NonCombat` (`0x8000003D`):
+
+```text
+flags.bit1 = current_style != NonCombat
+
+write flags
+if flags.bit0: write current_holdkey
+if flags.bit1: write current_style as uint32
+if flags.bit2: write forward_command
+... write the remaining axes and action queue ...
+```
+
+`RawMotionState::UnPack @ 0x0051EFC0` restores NonCombat when bit 1 is absent.
+Omitting the bit is therefore an explicit NonCombat value, not “preserve the
+receiver's previous stance.”
+
+## Reference cross-checks
+
+- ACE `Network/Motion/RawMotionState.cs` reads `RawMotionFlags.CurrentStyle`
+ (`0x2`) as a 32-bit stance.
+- ACE `Network/Motion/MovementData.cs` copies that field into the interpreted
+ state relayed to observers only when the flag is present.
+- acclientlib `UtilityBelt.Common/DataTypes.cs` independently documents that an
+ absent raw `CurrentStyle` defaults to `0x3D` NonCombat.
+
+Thus an axis-only acdream packet necessarily makes ACE/retail reconstruct
+NonCombat movement.
+
+## acdream root cause and port
+
+`PlayerMovementController` already owns retail's canonical
+`MotionInterpreter.RawState`, and inbound server stance changes correctly
+update `RawState.CurrentStyle`. However, `MovementResult` omitted that field and
+`LocalPlayerOutboundController.BuildRawMotionState` rebuilt only the hold key
+and three axes. Its new `RawMotionState` retained the constructor default
+NonCombat, so `RawMotionStatePacker` correctly omitted the CurrentStyle bit.
+
+Port the missing canonical field through the existing immutable frame result:
+
+```text
+MovementResult.CurrentStyle = MotionInterpreter.RawState.CurrentStyle
+
+BuildRawMotionState(result):
+ raw.CurrentStyle = result.CurrentStyle
+ raw.CurrentHoldKey = result current hold level
+ raw.forward/side/turn = result axes
+ return raw
+```
+
+This preserves the existing update-thread ownership and packet scheduler. It
+does not infer a stance from `CombatMode` or equipped items; the raw motion
+state remains the oracle, exactly as retail. The still-unwired raw action queue
+is separate and remains tracked by divergence TS-24.
diff --git a/docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md b/docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md
new file mode 100644
index 00000000..50115b59
--- /dev/null
+++ b/docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md
@@ -0,0 +1,95 @@
+# Local-player root-motion start — retail pseudocode (2026-07-17)
+
+## Symptom
+
+A short forward-key tap moved the local acdream body immediately while the
+Ready-to-locomotion animation was still starting. The character therefore
+glided before its legs began the corresponding movement.
+
+## Retail oracle
+
+Named Sept-2013 client:
+
+- `CMotionInterp::DoInterpretedMotion @ 0x00528360`
+- `CMotionInterp::apply_interpreted_movement @ 0x00528600`
+- `CMotionInterp::apply_current_movement @ 0x00528870`
+- `CMotionInterp::get_state_velocity @ 0x00527D50`
+- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`
+- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`
+- `CPhysicsObj::get_velocity @ 0x005113C0`
+
+Cross-checks:
+
+- `docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md`, which records
+ ACE's `PartArray.Update` into the shared offset frame before
+ `PositionManager.AdjustOffset`.
+- `docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md`, which
+ independently shows `apply_interpreted_movement` dispatching motions rather
+ than installing ordinary grounded velocity.
+
+## Pseudocode
+
+```text
+on input motion edge(command, params):
+ CMotionInterp updates raw/interpreted state
+ apply_interpreted_movement dispatches style, forward, sidestep, and turn
+ CPhysicsObj::DoInterpretedMotion forwards those operations to MotionTableManager
+ // No ordinary grounded set_local_velocity occurs here.
+
+CPhysicsObj::UpdatePositionInternal(dt):
+ offset = identity Frame
+
+ if visible and part_array exists:
+ part_array.Update(dt, offset)
+
+ if OnWalkable:
+ offset.origin *= object_scale
+ else:
+ offset.origin = zero
+
+ position_manager.adjust_offset(offset, dt)
+
+ candidate = current_position.frame combined with offset
+ candidate.origin += m_velocityVector * dt + acceleration * dt^2 / 2
+ m_velocityVector += acceleration * dt
+ return candidate
+
+CPhysicsObj::UpdateObjectInternal(dt):
+ old = current_position
+ candidate = UpdatePositionInternal(dt)
+ transition = sweep(old, candidate)
+ if transition succeeds:
+ cached_velocity = offset(old, resolved_position) / dt
+ SetPositionInternal(transition)
+ else:
+ cached_velocity = zero
+```
+
+`get_state_velocity` is not the ordinary grounded locomotion driver. Retail
+uses it for leave-ground/jump velocity and related state queries. Grounded
+translation comes from the complete `CSequence::update` Frame, including the
+authored Ready-to-Walk/Run link timing. `CPhysicsObj::get_velocity` returns the
+resolved `cached_velocity`, distinct from the integrator's
+`m_velocityVector`.
+
+## Port shape
+
+1. The local player's input edge must reach `MotionTableManager` before the
+ current object's animation advance.
+2. `AnimationSequencer.Advance(dt, rootFrame)` runs exactly once for the local
+ owner and publishes both the pose and `rootFrame.Origin`.
+3. `PlayerMovementController` accumulates that local displacement until the
+ existing 30 Hz physics quantum runs.
+4. While grounded, scale it by the server object scale and seed the same
+ `MotionDeltaFrame` passed through `PositionManager.AdjustOffset`.
+5. The transition sweep resolves the combined delta and computes cached
+ velocity from the realized displacement.
+6. Do not install `get_state_velocity()` as ordinary grounded body velocity
+ when a retail root-motion source is attached. Keep that function for
+ leave-ground/jump behavior.
+7. The animation pass consumes the already-advanced pose instead of advancing
+ the local sequence a second time.
+
+The existing manual yaw path remains separate; this slice consumes the root
+Frame's translation only. Full root-orientation ownership remains part of the
+registered turn/omega divergence and is not guessed here.
diff --git a/docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.md b/docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.md
new file mode 100644
index 00000000..be013fde
--- /dev/null
+++ b/docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.md
@@ -0,0 +1,115 @@
+# Remote root-motion reconciliation — retail pseudocode
+
+**Date:** 2026-07-17
+**Symptom:** an observed character walks forward, blips backward, then repeats.
+**Oracle:** Sept 2013 named retail decomp, installed retail DAT, ACE physics port.
+
+## Named retail path
+
+### `CPhysicsObj::UpdatePositionInternal` — `0x00512C30`
+
+```text
+offset = identity Frame
+
+if object is visible:
+ if partArray exists:
+ partArray.Update(dt, offset)
+ // CPartArray::Update 0x00517DB0 calls
+ // CSequence::update(sequence, dt, offset)
+
+ if OnWalkable:
+ offset.origin *= objectScale
+ else:
+ offset.origin = zero
+
+if positionManager exists:
+ positionManager.adjust_offset(offset, dt)
+ // InterpolationManager may REPLACE offset.origin with the
+ // queue-head catch-up delta.
+
+newFrame = currentWorldFrame combined with offset
+
+if object is visible:
+ UpdatePhysicsInternal(dt, newFrame)
+
+process_hooks()
+```
+
+### `add_motion` — `0x005224B0`
+
+```text
+if motionData exists:
+ sequence.velocity = motionData.velocity * speed
+ sequence.omega = motionData.omega * speed
+ append every authored AnimData * speed
+```
+
+There is no substitution of `CMotionInterp::get_state_velocity` constants into
+`CSequence::velocity`. Those constants drive body physics in the interpreted
+movement path; they are not animation-root data.
+
+### `InterpolationManager::adjust_offset` — `0x00555D30`
+
+```text
+if queue empty, no physics object, or object lacks Contact:
+ leave offset unchanged
+
+if queue head is within 0.05 m:
+ complete the node
+ leave offset unchanged
+
+maxSpeed = minterp.adjustedMaxSpeed * 2
+if maxSpeed is effectively zero:
+ maxSpeed = 7.5 m/s
+
+offset.origin = directionToHead * min(maxSpeed * dt, distanceToHead)
+```
+
+The queue catch-up replaces the CSequence displacement while active. Once the
+head is reached, the literal CSequence displacement remains.
+
+## Installed-DAT result
+
+`client_portal.dat` MotionTable `0x09000001` (Humanoid), NonCombat style
+`0x8000003D`:
+
+- WalkForward `0x40000005`: `MotionData.Velocity == (0,0,0)`
+- RunForward `0x40000007`: `MotionData.Velocity == (0,0,0)`
+
+Pinned by `HumanoidMotionTableRootMotionTests`.
+
+## acdream correction
+
+Previous behavior synthesized `WalkAnimSpeed × speed` or
+`RunAnimSpeed × speed` into `CSequence.Velocity`, then used that guessed value
+after the interpolation queue reached its latest waypoint. The body could run
+past server truth; the next UpdatePosition queued a backward correction,
+creating the repeating blip.
+
+Correct frame order:
+
+```text
+rootDelta = identity Frame
+partTransforms = sequencer.Advance(dt, rootDelta)
+worldDelta = RemoteMotionCombiner(
+ rootDelta.origin,
+ interpolationQueue,
+ positionManager)
+sweep and commit worldDelta
+publish root and part transforms
+```
+
+The client now consumes the complete root delta produced by the already-ported
+`CSequence` (authored PosFrames plus literal MotionData velocity). It does not
+derive remote translation from a command name or packet cadence.
+
+## Cross-reference
+
+- ACE `PhysicsObj.UpdatePositionInternal`: PartArray update → PositionManager
+ adjustment → world-frame combine, matching retail ordering.
+- ACE `InterpolationManager.adjust_offset`: queue correction replaces the
+ supplied frame origin and uses the same 0.05 m completion radius and 2× speed.
+- Earlier live retail cdb trace:
+ `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` — walking
+ remotes use queued position catch-up; their body velocity is not synthesized
+ from UpdatePosition.
diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
index b6a6d1cf..bff58f9b 100644
--- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs
+++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
@@ -180,6 +180,11 @@ public sealed class LocalPlayerOutboundController
return new RawMotionState
{
CurrentHoldKey = axisHoldKey,
+ // CommandInterpreter::SendMovementEvent @ 0x006B4680 passes
+ // CPhysicsObj::InqRawMotionState directly to MoveToStatePack.
+ // RawMotionState::UnPack @ 0x0051EFC0 treats an absent style as
+ // NonCombat, so this is state—not optional observer decoration.
+ CurrentStyle = movement.CurrentStyle,
ForwardCommand = movement.ForwardCommand
?? RawMotionState.Default.ForwardCommand,
ForwardHoldKey = movement.ForwardCommand.HasValue
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 4499f5b6..72a35fee 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -81,7 +81,12 @@ public readonly record struct MovementResult(
bool SidestepUsesRunHold = false,
// The host acknowledges this clock/queue only after the MoveToState has
// actually been put on the wire.
- bool IsMouseLookMovementEvent = false);
+ bool IsMouseLookMovementEvent = false,
+ // CommandInterpreter::SendMovementEvent @ 0x006B4680 passes the
+ // MovementManager's complete RawMotionState into MoveToStatePack. An
+ // absent style bit unpacks as NonCombat, so the canonical raw style must
+ // travel with every input-boundary snapshot sent to ACE.
+ uint CurrentStyle = 0x8000003Du);
///
/// Portal-space state for the player movement controller.
@@ -135,6 +140,13 @@ public sealed class PlayerMovementController
///
public float StepDownHeight { get; set; } = 0.4f;
+ ///
+ /// Retail CPhysicsObj::m_scale. Grounded CSequence root
+ /// displacement is multiplied by this value before PositionManager
+ /// composition (UpdatePositionInternal @ 0x00512C30).
+ ///
+ public float ObjectScale { get; set; } = 1f;
+
///
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
@@ -339,6 +351,8 @@ public sealed class PlayerMovementController
private float _physicsAccum;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
+ private Func? _advanceAnimationRootMotion;
+ private Vector3 _pendingAnimationRootMotion;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
@@ -586,6 +600,7 @@ public sealed class PlayerMovementController
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
+
_prevRunHold = input.Run;
_body.LastMoveWasAutonomous = true;
}
@@ -867,6 +882,20 @@ public sealed class PlayerMovementController
_motion.GetCycleVelocity = accessor;
}
+ ///
+ /// Binds the owning PartArray/CSequence update to the local physics tick.
+ /// The callback runs after this frame's input edges have reached the motion
+ /// table and returns the complete local translation emitted by
+ /// CSequence::update. The controller accumulates sub-quantum deltas
+ /// and consumes them through the same PositionManager frame as retail.
+ ///
+ public void AttachAnimationRootMotionSource(Func advance)
+ {
+ _advanceAnimationRootMotion = advance
+ ?? throw new ArgumentNullException(nameof(advance));
+ _pendingAnimationRootMotion = Vector3.Zero;
+ }
+
///
/// Retail CommandInterpreter::SendMovementEvent (0x006B4680)
/// stamps only last_sent_position_time. The carried frame and
@@ -913,7 +942,8 @@ public sealed class PlayerMovementController
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
SidestepUsesRunHold: sidestep.HasValue
&& raw.SidestepHoldKey == HoldKey.Run,
- IsMouseLookMovementEvent: mouseLookEvent);
+ IsMouseLookMovementEvent: mouseLookEvent,
+ CurrentStyle: raw.CurrentStyle);
}
///
@@ -1065,6 +1095,7 @@ public sealed class PlayerMovementController
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
_physicsAccum = 0f;
+ _pendingAnimationRootMotion = Vector3.Zero;
}
///
@@ -1078,6 +1109,7 @@ public sealed class PlayerMovementController
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
+ _pendingAnimationRootMotion = Vector3.Zero;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
}
@@ -1098,6 +1130,7 @@ public sealed class PlayerMovementController
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
+ _pendingAnimationRootMotion = Vector3.Zero;
Vector3 previousPosition = _body.Position;
bool previousContact = _body.InContact;
bool previousOnWalkable = _body.OnWalkable;
@@ -1164,7 +1197,8 @@ public sealed class PlayerMovementController
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
- TurnSpeed: null);
+ TurnSpeed: null,
+ CurrentStyle: _motion.RawState.CurrentStyle);
}
public MovementResult Update(float dt, MovementInput input)
@@ -1219,7 +1253,8 @@ public sealed class PlayerMovementController
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
- TurnSpeed: null);
+ TurnSpeed: null,
+ CurrentStyle: _motion.RawState.CurrentStyle);
}
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
@@ -1385,6 +1420,21 @@ public sealed class PlayerMovementController
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
+ // Retail input reaches MotionTableManager before this object's
+ // CPartArray update. Advance only after the edge dispatch above so a
+ // Ready-to-Walk/Run link contributes its authored displacement on the
+ // same object tick. Sub-quantum render frames accumulate until the
+ // 30 Hz physics gate consumes them below.
+ bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null;
+ if (_advanceAnimationRootMotion is { } advanceRootMotion)
+ {
+ Vector3 rootDelta = advanceRootMotion(dt);
+ if (_body.OnWalkable)
+ _pendingAnimationRootMotion += rootDelta * ObjectScale;
+ else
+ _pendingAnimationRootMotion = Vector3.Zero;
+ }
+
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
@@ -1423,41 +1473,32 @@ public sealed class PlayerMovementController
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
- // ── 2. Set velocity via MotionInterpreter state machine ───────────────
- // R4-V5: runs during a moveto too — the manager's BeginMoveForward
- // dispatched WalkForward/RunForward through _DoMotion into the SAME
- // interpreted state, so this one per-frame apply turns it into body
- // velocity exactly like input-driven motion (the #75 "two writers"
- // hazard is gone: there is only this writer now).
-
- // D6.2: the forward/sidestep command determination + DoMotion +
- // DoInterpretedMotion moved into the apply_raw_movement call above, so
- // the interpreted state (and thus get_state_velocity) is already
- // populated + normalized for all directions.
-
- // Only replace velocity with motion interpreter output when grounded.
- // While airborne, the physics body's integrated velocity (from LeaveGround)
- // persists — gravity pulls Z down, horizontal momentum is preserved.
- // Retail AC works this way: you maintain momentum in the air.
+ // ── 2. Install grounded movement source ───────────────────────────────
+ // Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
+ // PartArray into one local Frame. Ordinary grounded movement is that
+ // animation-authored displacement, not get_state_velocity(). The
+ // latter remains the explicit fallback for headless/test controllers
+ // that have no attached animation runtime and for jump launch below.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
- // D6.2: velocity for ALL directions comes from the normalized
- // interpreted state — backward (WalkForward ×-0.65) and strafe-left
- // (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
- // that adjust_motion ran above. The hand-mirrored formulas are gone
- // (register TS-22 retired).
- var stateVel = _motion.get_state_velocity();
- // R4-V5 wedge fix: PRESERVE the flag — retail's
- // last_move_was_autonomous is stored at EVENT boundaries only
- // (input DoMotion/StopMotion edges above, the P1 wire-unpack
- // store, LeaveGround's own autonomous=1 write), never re-stamped
- // by the per-tick velocity write. V5's first cut stamped it per
- // frame here, which mis-routed the pump's A3 dual dispatch on
- // event-transition frames (apply_raw clobbering a just-armed
- // moveto's dispatched motion with the raw Ready state).
- _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
- autonomous: _body.LastMoveWasAutonomous);
+ if (hasAnimationRootMotion)
+ {
+ // Do not let m_velocityVector move the body a second time.
+ // The pending animation delta is composed with the
+ // PositionManager below and swept through the normal collision
+ // path in the same physics quantum.
+ _body.Velocity = new Vector3(0f, 0f, savedWorldVz);
+ }
+ else
+ {
+ var stateVel = _motion.get_state_velocity();
+ // Preserve LastMoveWasAutonomous: retail stores it at motion
+ // event boundaries, never at this per-tick fallback write.
+ _body.set_local_velocity(
+ new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
+ autonomous: _body.LastMoveWasAutonomous);
+ }
}
// ── 3. Jump (charged) ─────────────────────────────────────────────────
@@ -1540,6 +1581,7 @@ public sealed class PlayerMovementController
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
+ _pendingAnimationRootMotion = Vector3.Zero;
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
}
@@ -1559,18 +1601,21 @@ public sealed class PlayerMovementController
// body orientation); the heading is RELATIVE and writes Yaw —
// the authoritative facing the body quaternion is re-derived
// from every frame (a quaternion-only write would be clobbered).
- // No-op while nothing is stuck (untouched delta frame).
- if (PositionManager is { } ppm)
- {
- var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
- ppm.AdjustOffset(pmDelta, tickDt);
- if (pmDelta.Origin != Vector3.Zero)
- _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
- if (!pmDelta.Orientation.IsIdentity)
- Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
- AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
- + pmDelta.GetHeading());
- }
+ // Seed the SAME frame that PositionManager receives. Retail's
+ // Sticky interpolation deliberately replaces PartArray root
+ // displacement rather than adding a second movement source.
+ var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
+ if (hasAnimationRootMotion && _body.OnWalkable)
+ pmDelta.Origin = _pendingAnimationRootMotion;
+ _pendingAnimationRootMotion = Vector3.Zero;
+
+ PositionManager?.AdjustOffset(pmDelta, tickDt);
+ if (pmDelta.Origin != Vector3.Zero)
+ _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
+ if (!pmDelta.Orientation.IsIdentity)
+ Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
+ AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ + pmDelta.GetHeading());
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
@@ -1868,7 +1913,8 @@ public sealed class PlayerMovementController
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
&& outSidestepCmd.HasValue,
- IsMouseLookMovementEvent: mouseMovementEventDue);
+ IsMouseLookMovementEvent: mouseMovementEventDue,
+ CurrentStyle: _motion.RawState.CurrentStyle);
}
///
diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
index 8ee7098a..6ac2a424 100644
--- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
+++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
@@ -92,9 +92,18 @@ internal sealed class RemotePhysicsUpdater
/// body. serverGuid + the entity id derive from
/// .Entity; /
/// are passed per-call (they change on streaming recentre — never snapshot
- /// them in the constructor).
+ /// them in the constructor). is
+ /// the complete local displacement produced by this frame's preceding
+ /// CSequence::update, matching retail's
+ /// CPartArray::Update → PositionManager::adjust_offset order.
///
- public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
+ public void Tick(
+ RemoteMotion rm,
+ AnimatedEntity ae,
+ float dt,
+ System.Numerics.Vector3 rootMotionLocalDelta,
+ int liveCenterX,
+ int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
@@ -124,6 +133,16 @@ internal sealed class RemotePhysicsUpdater
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ // Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
+ // the CSequence root displacement by m_scale only while the body
+ // is OnWalkable; otherwise it clears the displacement. Grounded
+ // remotes below are explicitly OnWalkable and airborne remotes use
+ // their authoritative velocity/gravity arc, so this is the same
+ // branch expressed through our retained runtime state.
+ System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
+ ? rootMotionLocalDelta * ae.Scale
+ : System.Numerics.Vector3.Zero;
+
// 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
@@ -293,8 +312,6 @@ internal sealed class RemotePhysicsUpdater
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(
@@ -302,7 +319,7 @@ internal sealed class RemotePhysicsUpdater
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
- seqVel: seqVelNpc,
+ rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
@@ -324,8 +341,6 @@ internal sealed class RemotePhysicsUpdater
{
// 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(
@@ -333,7 +348,7 @@ internal sealed class RemotePhysicsUpdater
rm.Body.Position += rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
- seqVel: seqVelNpc,
+ rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 76fab1d4..d7692454 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -303,6 +303,15 @@ public sealed class GameWindow : IDisposable
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
+ // The local player's CPartArray is advanced from
+ // PlayerMovementController before its 30 Hz physics integration so
+ // the exact authored root delta enters the collision sweep. Retain
+ // that same advance's part pose for TickAnimations; advancing the
+ // sequencer a second time would double both animation time and hooks.
+ public IReadOnlyList? PreparedSequenceFrames;
+ public bool SequenceAdvancedBeforeAnimationPass;
+ public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
+
///
/// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every
/// animated entity (including idle NPCs on a breathe cycle) used to
@@ -422,22 +431,18 @@ public sealed class GameWindow : IDisposable
_remoteLastMove = new();
///
- /// Per-remote-entity dead-reckoning state for smoothing between server
- /// UpdatePosition broadcasts. Without this, remote characters teleport
- /// every ~100–200 ms when the server pushes a new position (the retail
- /// client hides the gap by integrating CMotionInterp-surfaced
- /// velocity forward each tick — see chunk_00520000.c
- /// apply_current_movement L7132-L7189 and holtburger's
- /// spatial/physics.rs::project_pose_by_velocity).
+ /// Per-remote-entity movement state for smoothing between server
+ /// UpdatePosition broadcasts. Retail queues each received position and
+ /// advances the body toward the queue head through
+ /// InterpolationManager::adjust_offset every object tick.
///
///
- /// Each entry records the last authoritative server position + time + a
- /// measured velocity inferred from the delta between consecutive
- /// UpdatePositions. The client's per-tick integrator uses the
- /// sequencer's CurrentVelocity (rotated into world space by the
- /// entity's orientation) as the primary source and falls back to the
- /// inferred velocity when the motion table doesn't carry one (e.g. NPC
- /// motion tables with HasVelocity=0).
+ /// Each entry owns the retail interpolation queue, MovementManager,
+ /// PhysicsBody, latest authoritative position, and the velocity estimate
+ /// used only to select animation cycles for NPCs that receive no motion
+ /// messages. Body translation consumes queue catch-up plus the literal
+ /// root-motion Frame emitted by CSequence; it never guesses movement from
+ /// a Walk/Run command or from packet cadence.
///
///
private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon;
@@ -684,13 +689,10 @@ public sealed class GameWindow : IDisposable
///
/// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the
/// previous UpdatePosition's world position + timestamp. The per-tick
- /// path computes (serverPos - prevServerPos) / dt and compares
- /// it to the sequencer's CurrentVelocity. The ratio tells us
- /// whether the local-prediction speed (animation root motion) is
- /// outrunning the server's actual broadcast pace, which would cause
- /// the InterpolationManager queue to walk back the body each UP and
- /// produce visible 1-Hz blips. Read in TickAnimations and throttled
- /// to one log line per remote per ~2 seconds.
+ /// path compares the server's broadcast pace with the literal root
+ /// displacement emitted by CSequence::update. The old inferred
+ /// walk/run velocity was the source of the periodic overshoot this
+ /// diagnostic was introduced to find.
///
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
@@ -713,12 +715,11 @@ public sealed class GameWindow : IDisposable
///
public double LastPartsDiagLogTime;
///
- /// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
- /// all per-tick samples since the last UpdatePosition arrival. The
- /// next UP compares this against (LastServerPos - PrevServerPos) /
- /// dtServer to compute the overshoot ratio. Reset on each UP.
+ /// Diagnostic-only: maximum scaled CSequence root-motion speed
+ /// observed since the last UpdatePosition arrival. The next UP
+ /// compares it with the server's observed pace. Reset on each UP.
///
- public float MaxSeqSpeedSinceLastUP;
+ public float MaxRootMotionSpeedSinceLastUP;
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
{
@@ -6476,8 +6477,8 @@ public sealed class GameWindow : IDisposable
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
// server-pos snapshot forward AND print the per-UP comparison
- // between the max sequencer speed observed since last UP and
- // the actual server broadcast pace. Both sides are now sampled
+ // between the max literal CSequence root-motion speed observed
+ // since the last UP and the actual server broadcast pace. Both are sampled
// over the same window so the ratio reflects real overshoot.
{
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
@@ -6489,17 +6490,17 @@ public sealed class GameWindow : IDisposable
{
var serverDelta = worldPos - rmState.LastServerPos;
float serverSpeed = (float)(serverDelta.Length() / dtServer);
- float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
- if (serverSpeed > 0.1f || seqSpeed > 0.1f)
+ float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
+ if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
{
System.Console.WriteLine(
- $"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
+ $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
- + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
+ + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
}
}
}
- rmState.MaxSeqSpeedSinceLastUP = 0f;
+ rmState.MaxRootMotionSpeedSinceLastUP = 0f;
rmState.PrevServerPos = rmState.LastServerPos;
rmState.PrevServerPosTime = rmState.LastServerPosTime;
rmState.LastServerPos = worldPos;
@@ -9485,6 +9486,11 @@ public sealed class GameWindow : IDisposable
{
_frameProfiler.FrameBoundary(_gl!);
+ // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
+ // normal SmartBox viewport and UIElement_Viewport's portal
+ // CreatureMode; it does not redraw the world behind portal space.
+ bool portalViewportVisible = _portalTunnel?.IsVisible == true;
+
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
@@ -9500,11 +9506,22 @@ public sealed class GameWindow : IDisposable
// since keyframes may pre-multiply DirBright and produce over-1
// values that some drivers interpret as "bright clamp" (pink/green
// frames).
- _gl!.ClearColor(
- System.Math.Clamp(fogColor.X, 0f, 1f),
- System.Math.Clamp(fogColor.Y, 0f, 1f),
- System.Math.Clamp(fogColor.Z, 0f, 1f),
- 1f);
+ if (portalViewportVisible)
+ {
+ // SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
+ // with RenderDevice.Clear(7, black, 1). The portal viewport later
+ // clears depth only, preserving this frame's black target—not a
+ // previous swap-chain image or the hidden destination world.
+ _gl!.ClearColor(0f, 0f, 0f, 1f);
+ }
+ else
+ {
+ _gl!.ClearColor(
+ System.Math.Clamp(fogColor.X, 0f, 1f),
+ System.Math.Clamp(fogColor.Y, 0f, 1f),
+ System.Math.Clamp(fogColor.Z, 0f, 1f),
+ 1f);
+ }
// §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
// write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
@@ -9514,7 +9531,7 @@ public sealed class GameWindow : IDisposable
// feedback_render_self_contained_gl_state: the clear site asserts the state it
// depends on rather than inheriting it. The [gl-state] tripwire still detects
// any future leak.
- _gl.DepthMask(true);
+ _gl!.DepthMask(true);
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// WB GameScene.cs:830-843 establishes CW as the frame-global
@@ -9582,7 +9599,7 @@ public sealed class GameWindow : IDisposable
int visibleLandblocks = 0;
int totalLandblocks = 0;
- if (_cameraController is not null)
+ if (_cameraController is not null && !portalViewportVisible)
{
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
@@ -10492,7 +10509,8 @@ public sealed class GameWindow : IDisposable
}
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
- // CreatureMode portal-space viewport. This replacement scene and
+ // CreatureMode portal-space viewport. The world block above is skipped
+ // while this replacement scene is visible. This scene and
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply(
@@ -10736,6 +10754,31 @@ public sealed class GameWindow : IDisposable
/// entities (no AnimatedEntity record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call.
///
+ private System.Numerics.Vector3 AdvanceLocalPlayerAnimationRoot(float dt)
+ {
+ if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
+ || !_animatedEntities.TryGetValue(entity.Id, out var ae)
+ || ae.Sequencer is not { } sequencer
+ || _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false
+ || _liveEntities?.IsHidden(_playerServerGuid) == true)
+ {
+ return System.Numerics.Vector3.Zero;
+ }
+
+ EnsureMotionDoneBinding(_playerServerGuid, ae);
+
+ DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
+ rootFrame.Origin = System.Numerics.Vector3.Zero;
+ rootFrame.Orientation = System.Numerics.Quaternion.Identity;
+ ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame);
+ ae.SequenceAdvancedBeforeAnimationPass = true;
+
+ // TickAnimations will publish this advance's final part transforms
+ // before the deferred hook frame is drained.
+ _animationHookFrames?.Capture(ae.Entity.Id, sequencer);
+ return rootFrame.Origin;
+ }
+
private void TickAnimations(float dt)
{
// Retail has NO stop-detection heuristic — it relies on the
@@ -10752,6 +10795,12 @@ public sealed class GameWindow : IDisposable
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
+ bool sequenceAdvancedBeforeAnimationPass =
+ ae.SequenceAdvancedBeforeAnimationPass;
+ IReadOnlyList? preparedSequenceFrames =
+ ae.PreparedSequenceFrames;
+ ae.SequenceAdvancedBeforeAnimationPass = false;
+ ae.PreparedSequenceFrames = null;
// The server guid is carried on the entity itself
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
@@ -10790,21 +10839,19 @@ public sealed class GameWindow : IDisposable
bool hidden = serverGuid != 0
&& _liveEntities?.IsHidden(serverGuid) == true;
- // ── Dead-reckoning: smooth position between UpdatePosition bursts.
- // The server broadcasts UpdatePosition at ~5-10Hz for distant
- // entities; without integration, remote chars jitter-hop between
- // samples. Each tick we advance entity.Position by the
- // sequencer's current velocity (rotated into world space by the
- // entity's facing) — matching the retail client's
- // apply_current_movement (chunk_00520000.c L7132-L7189) and
- // holtburger's project_pose_by_velocity.
- //
- // The cap on predict-distance from the last server pos prevents
- // runaway when the sequencer's velocity and the server's reality
- // disagree (e.g. server is rubber-banding the entity). Retail
- // uses a similar clamp at PhysicsObj::IsInterpolationComplete.
+ // CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
+ // CPartArray FIRST into a local root-motion Frame, then lets the
+ // PositionManager replace that Frame with interpolation catch-up.
+ // Advance remote sequences here so the physics owner consumes the
+ // literal CSequence delta; reconstructing a velocity from the
+ // interpreted Walk/Run command made bodies run past the queue head
+ // and snap backward on the next server position.
+ IReadOnlyList? seqFrames =
+ sequenceAdvancedBeforeAnimationPass ? preparedSequenceFrames : null;
+ bool sequencerAdvancedForObject = sequenceAdvancedBeforeAnimationPass;
if (!hidden
&& ae.Sequencer is not null
+ && !sequencerAdvancedForObject
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
@@ -10822,11 +10869,32 @@ public sealed class GameWindow : IDisposable
// TickAnimations advance.
&& rm.LastServerPosTime > 0)
{
- _remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY);
+ var remoteRootMotionFrame = new DatReaderWriter.Types.Frame
+ {
+ Origin = System.Numerics.Vector3.Zero,
+ Orientation = System.Numerics.Quaternion.Identity,
+ };
+ seqFrames = ae.Sequencer.Advance(dt, remoteRootMotionFrame);
+ _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
+ sequencerAdvancedForObject = true;
+ if (dt > 0f)
+ {
+ float rootMotionSpeed = remoteRootMotionFrame.Origin.Length()
+ * ae.Scale / dt;
+ rm.MaxRootMotionSpeedSinceLastUP = MathF.Max(
+ rm.MaxRootMotionSpeedSinceLastUP,
+ rootMotionSpeed);
+ }
+ _remotePhysicsUpdater.Tick(
+ rm,
+ ae,
+ dt,
+ remoteRootMotionFrame.Origin,
+ _liveCenterX,
+ _liveCenterY);
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
- IReadOnlyList? seqFrames = null;
if (ae.Sequencer is not null)
{
// Per-tick sequencer-state diag: prove whether the sequencer
@@ -10869,18 +10937,21 @@ public sealed class GameWindow : IDisposable
rmDiag.LastSeqStateLogTime = nowSec;
}
}
- seqFrames = hidden
- ? ae.Sequencer.SampleCurrentPose()
- : ae.Sequencer.Advance(dt);
+ if (!sequencerAdvancedForObject)
+ {
+ seqFrames = hidden
+ ? ae.Sequencer.SampleCurrentPose()
+ : ae.Sequencer.Advance(dt);
- // Capture hooks now, but deliver them only after every final
- // part transform and equipped-child root has been published.
- // This matches CPhysicsObj::UpdateObjectInternal followed by
- // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
- // reads this frame's pose, never the previous frame's pose.
- // AnimationDone/UseTime remain paired with the deferred drain.
- if (!hidden)
- _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
+ // Capture hooks now, but deliver them only after every final
+ // part transform and equipped-child root has been published.
+ // This matches CPhysicsObj::UpdateObjectInternal followed by
+ // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
+ // reads this frame's pose, never the previous frame's pose.
+ // AnimationDone/UseTime remain paired with the deferred drain.
+ if (!hidden)
+ _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
+ }
}
else
{
@@ -14231,6 +14302,8 @@ public sealed class GameWindow : IDisposable
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
+ _playerController.ObjectScale = playerAE.Scale;
+ _playerController.AttachAnimationRootMotionSource(AdvanceLocalPlayerAnimationRoot);
// R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement).
diff --git a/src/AcDream.App/Rendering/PortalTunnelCamera.cs b/src/AcDream.App/Rendering/PortalTunnelCamera.cs
index 2d3dad91..7126b4a9 100644
--- a/src/AcDream.App/Rendering/PortalTunnelCamera.cs
+++ b/src/AcDream.App/Rendering/PortalTunnelCamera.cs
@@ -15,14 +15,18 @@ public sealed class PortalTunnelCamera : ICamera
public float DirectionDegrees { get; set; }
public float FovRadians { get; set; } = MathF.PI / 4f;
public float Near { get; set; } = 0.1f;
- public float Far { get; set; } = 50f;
+ // Render::zfar defaults to 4000 in retail (0x0081EC88). CreatureMode::Render
+ // does not install a private far plane: PrimD3DRender::SetFOVInternal
+ // (0x0059AB40) rebuilds the portal projection with that shared value.
+ public float Far { get; set; } = 4000f;
public float Aspect { get; set; } = 1f;
///
/// Retail CreatureMode::UseSmartboxFOV reads the active SmartBox
/// projection each draw. Recover its vertical field of view and near
- /// plane from that perspective matrix while retaining this private
- /// scene's bounded far plane.
+ /// and far planes from that perspective matrix. Retail's CreatureMode
+ /// changes the FOV through the SmartBox path while retaining the renderer's
+ /// shared near/far clip range.
///
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
{
@@ -40,6 +44,11 @@ public sealed class PortalTunnelCamera : ICamera
float near = smartBoxProjection.M33 != 0f
? smartBoxProjection.M43 / smartBoxProjection.M33
: float.NaN;
+ float far = smartBoxProjection.M33 != -1f
+ ? smartBoxProjection.M43 / (smartBoxProjection.M33 + 1f)
+ : float.NaN;
+ if (float.IsFinite(far) && far > 0f)
+ Far = far;
if (float.IsFinite(near) && near > 0f && near < Far)
Near = near;
}
diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
index 4c5bbd88..f0c0a40d 100644
--- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
+++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
@@ -27,6 +27,13 @@ public sealed class PortalTunnelPresentation : IDisposable
public const uint AnimationClientEnum = 0x10000002u;
public const uint ClientEnumCategory = 7u;
+ // UIViewportObject::DrawContent @ 0x006950A5 calls
+ // RenderDeviceD3D::Clear(4, RGBAColor_Black, 1). Clear @ 0x0059FD30
+ // maps retail flag 4 to D3DCLEAR_ZBUFFER only: portal space deliberately
+ // preserves the black color target established by SceneTool::BeginScene's
+ // whole-frame Clear(7) while replacing the hidden world viewport.
+ internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit;
+
private const uint SyntheticEntityId = 0xFFFF_FF01u;
private const uint SyntheticLandblockId = 0u;
private const float RotationDurationMin = 0.6f;
@@ -222,8 +229,9 @@ public sealed class PortalTunnelPresentation : IDisposable
}
///
- /// Replace the already-rendered world viewport with retail portal space.
- /// The caller then draws retained UI above this pass.
+ /// Draw retail portal space into the active viewport. The caller suppresses
+ /// the normal world viewport while this scene is visible, then draws the
+ /// retained UI above it.
///
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{
@@ -236,10 +244,9 @@ public sealed class PortalTunnelPresentation : IDisposable
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
- _gl.ClearColor(0f, 0f, 0f, 1f);
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
- _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
+ _gl.Clear(RetailViewportClearMask);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
diff --git a/src/AcDream.App/Rendering/Sky/SkyProjection.cs b/src/AcDream.App/Rendering/Sky/SkyProjection.cs
new file mode 100644
index 00000000..674ec89b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Sky/SkyProjection.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Sky;
+
+///
+/// Builds the temporary sky projection used by retail GameSky::Draw
+/// (0x00506FF0): preserve the active viewport's field of view and
+/// projection handedness, changing only its near/far depth mapping.
+///
+internal static class SkyProjection
+{
+ public static Matrix4x4 WithDepthRange(
+ in Matrix4x4 activeProjection,
+ float near,
+ float far)
+ {
+ if (!float.IsFinite(near) || !float.IsFinite(far)
+ || near <= 0f || far <= near)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(far),
+ "Sky depth range must be finite with 0 < near < far.");
+ }
+
+ var result = activeProjection;
+
+ // System.Numerics perspective matrices use M34=-1 (right-handed).
+ // Keep the source matrix's X/Y scale and offsets verbatim so a
+ // teleport projection remains pixel-aligned with terrain. The
+ // positive branch also preserves a left-handed source should one be
+ // supplied by a future backend.
+ if (activeProjection.M34 < 0f)
+ {
+ result.M33 = far / (near - far);
+ result.M43 = near * far / (near - far);
+ }
+ else if (activeProjection.M34 > 0f)
+ {
+ result.M33 = far / (far - near);
+ result.M43 = -near * far / (far - near);
+ }
+ else
+ {
+ throw new ArgumentException(
+ "Sky projection must be perspective (M34 cannot be zero).",
+ nameof(activeProjection));
+ }
+
+ return result;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index 29cba3f7..a02275c5 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -162,13 +162,13 @@ public sealed unsafe class SkyRenderer : IDisposable
{
if (group is null || group.SkyObjects.Count == 0) return;
- // Build a sky projection with a huge far plane so 1e6m-distant
- // celestial meshes don't clip. The FOV is cargo-culted from the
- // camera's projection — see WorldBuilder's implementation.
- float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
- float aspect = camera.Aspect;
- if (aspect <= 0f) aspect = 16f / 9f;
- var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far);
+ // Keep sky meshes inside their authored depth range without
+ // replacing the active viewport's horizontal/vertical projection.
+ // Retail installs SmartBox's active projection before LScape::draw
+ // (SmartBox::RenderNormalMode @ 0x00453AA0), then GameSky::Draw
+ // (@ 0x00506FF0) changes ONLY zfar to 4x for the sky draw. Preserve
+ // that FOV here, including the near-180-degree teleport transition.
+ var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
// View with translation zeroed — keeps the sky at camera origin
// regardless of camera position in the world.
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index c1bf24e8..8d43be79 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -215,7 +215,12 @@ public sealed class RetailUiRuntime : IDisposable
probe,
bindings.Probe.ScriptPath,
bindings.Probe.DumpOnStart,
- bindings.Probe.Log);
+ bindings.Probe.Log,
+ text => ChatCommandRouter.Submit(
+ text,
+ bindings.Chat.ViewModel,
+ bindings.Chat.CommandBus(),
+ ChatChannelKind.Say));
}
}
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
index ed17b87d..65f43ea6 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
@@ -16,6 +16,7 @@ public sealed class RetailUiAutomationScriptRunner
{
private readonly RetailUiAutomationProbe _probe;
private readonly Action _log;
+ private readonly Action? _submitCommand;
private readonly List _commands = new();
private readonly bool _dumpOnStart;
private readonly string? _loadError;
@@ -30,10 +31,12 @@ public sealed class RetailUiAutomationScriptRunner
RetailUiAutomationProbe probe,
string? scriptPath,
bool dumpOnStart,
- Action? log = null)
+ Action? log = null,
+ Action? submitCommand = null)
{
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { });
+ _submitCommand = submitCommand;
_dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath))
@@ -125,6 +128,7 @@ public sealed class RetailUiAutomationScriptRunner
"wait" => DoWait(command),
"sleep" => DoSleep(command),
"assert" => DoAssert(command),
+ "command" => DoCommand(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
@@ -264,6 +268,18 @@ public sealed class RetailUiAutomationScriptRunner
return result.Success || Stop(command, result.Message);
}
+ private bool DoCommand(ScriptCommand command)
+ {
+ string text = command.Text[command.Parts[0].Length..].Trim();
+ if (text.Length == 0)
+ return Stop(command, "usage: command ");
+ if (_submitCommand is null)
+ return Stop(command, "command submission is unavailable");
+
+ _submitCommand(text);
+ return true;
+ }
+
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;
diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index cfe776ff..15fc1fa0 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -29,9 +29,11 @@ public interface IAnimationLoader
// public member's signature (the API-migration table in the gap map is the
// contract) and re-expresses each one over the core. Invented mechanisms that
// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion
-// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega
-// synthesis constants) SURVIVE unchanged at the adapter level per the gap
-// map's "Invented behaviors NOT in the gap list" note.
+// link-skip, stop-anim fallback, GetLink's reversed branch, turn-omega
+// synthesis) survive at the adapter level per the gap map's
+// "Invented behaviors NOT in the gap list" note. Locomotion-velocity
+// synthesis was retired when remote physics began consuming the literal
+// CSequence root-motion Frame (issue #41, 2026-07-17).
//
// Primary references (docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md):
// append_animation 0x00525510 (§24)
@@ -380,10 +382,9 @@ public sealed class AnimationSequencer
if (style != 0 && style != _state.Style)
_manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f));
- // Motion via the PerformMovement passthrough (velocity synthesis on
- // success — same helper the R2-Q5 funnel sink path uses; the
- // adjusted motion + adjusted speed produce the identical synthesis
- // result as the original pair).
+ // Motion via the PerformMovement passthrough. CMotionTable/add_motion
+ // owns the literal sequence velocity/omega writes; command-derived
+ // body speed is not copied into CSequence.
uint dispatchResult = PerformMovement(
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
@@ -394,16 +395,15 @@ public sealed class AnimationSequencer
// RemoveAllLinkAnimations) when the body leaves the ground.
// Failed dispatch (missing cycle for this style, is_allowed reject):
- // retail leaves sequence AND state untouched — no synthesis either.
- // (Velocity synthesis already ran inside PerformMovement on success.)
+ // retail leaves sequence AND state untouched.
if (dispatchResult != MotionTableManagerError.Success)
return;
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
- // Same story as velocity synthesis above: Humanoid turn MotionData
- // often ships without HasOmega. Retail clients turn the body via
- // the baked omega, but if the dat is silent we fall back to the
- // retail turn-rate constant. Decompile references:
+ // Humanoid turn MotionData often ships without HasOmega. Until the
+ // remaining R6 rotation path consumes CSequence's complete Frame
+ // orientation, the remote ObservedOmega seam needs the retail
+ // turn-rate fallback. Decompile references:
// FUN_00529210 apply_current_movement (writes Omega)
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
@@ -455,72 +455,18 @@ public sealed class AnimationSequencer
///
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
- /// , then (on a
- /// successful InterpretedCommand) the locomotion velocity synthesis
- /// (register AP-75; the consumers are remote body translation via
- /// RemoteMotionCombiner.ComputeOffset and the local Option-B
- /// get_state_velocity — retire in R6 when root motion drives the body).
- /// Omega is deliberately NOT synthesized here: remote rotation is the
- /// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
- /// only the SetCycle path keeps the turn-omega fallback.
+ /// . The resulting
+ /// remains the literal retail
+ /// MotionData.Velocity * speed written by add_motion;
+ /// command-derived body speed belongs to
+ /// CMotionInterp::get_state_velocity, not to CSequence.
///
public uint PerformMovement(MotionTableMovement movement)
{
EnsureInitialized();
- uint result = _manager.PerformMovement(movement);
- if (result == MotionTableManagerError.Success
- && movement.Type == MovementType.InterpretedCommand)
- {
- SynthesizeLocomotionVelocity(movement.Motion, movement.Speed);
- }
- return result;
+ return _manager.PerformMovement(movement);
}
- ///
- /// Overwrite sequence velocity with the retail locomotion constant when
- /// the dispatched motion is a locomotion cycle. The Humanoid motion
- /// table ships every locomotion MotionData with a zero Velocity, so
- /// add_motion's unconditional set leaves the sequence at zero —
- /// but retail's body physics uses CMotionInterp::get_state_velocity
- /// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the
- /// dat), so consumers of need the
- /// constant here. Velocity is body-local (+Y forward, +X right);
- /// consumers rotate into world space via the entity orientation.
- /// Constants decompiled from _DAT_007c96e0/e4/e8.
- ///
- private void SynthesizeLocomotionVelocity(uint motion, float speed)
- {
- float yvel = 0f;
- float xvel = 0f;
- bool isLocomotion = false;
- switch (motion & 0xFFu)
- {
- case 0x05: // WalkForward
- case 0x06: // WalkBackward (pre-adjust callers; adjusted = 0x05)
- yvel = WalkAnimSpeed * speed;
- isLocomotion = true;
- break;
- case 0x07: // RunForward
- yvel = RunAnimSpeed * speed;
- isLocomotion = true;
- break;
- case 0x0F: // SideStepRight
- case 0x10: // SideStepLeft (pre-adjust callers; adjusted = 0x0F)
- xvel = SidestepAnimSpeed * speed;
- isLocomotion = true;
- break;
- }
- if (isLocomotion)
- _core.SetVelocity(new Vector3(xvel, yvel, 0f));
- }
-
- // Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed
- // etc. Kept here to keep AnimationSequencer self-contained for the
- // synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8.
- private const float WalkAnimSpeed = 3.12f;
- private const float RunAnimSpeed = 4.0f;
- private const float SidestepAnimSpeed = 1.25f;
-
// R2-Q4: MultiplyCyclicFramerate DELETED (zero external callers). The
// gap-map-G13 composite stand-in (framerate scale + velocity/omega
// rescale) is retired — same-motion re-speeds now route through the
diff --git a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs
index eaf86fe1..0ebb65d0 100644
--- a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs
+++ b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs
@@ -3,20 +3,22 @@ using System.Numerics;
namespace AcDream.Core.Physics;
///
-/// Per-frame combiner for remote-entity motion: animation root motion
-/// + InterpolationManager catch-up correction. Pure function — no
-/// side effects, no hidden state.
+/// Per-frame combiner for remote-entity motion: the root-motion delta emitted
+/// by CSequence::update + InterpolationManager catch-up correction.
+/// Pure function — no side effects or hidden state.
///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
///
-/// In acdream the animation root motion is sourced from
-/// AnimationSequencer.CurrentVelocity (body-local velocity from the
-/// active locomotion cycle). We rotate that by the body's orientation
-/// to get a world-space delta, then add the InterpolationManager's
-/// world-space correction.
+/// The animation root motion is the complete body-local Frame.Origin
+/// accumulated by CPartArray::Update: authored PosFrames plus the
+/// literal sequence velocity. We rotate that delta by the body's orientation
+/// to get world space. It must not be reconstructed from the interpreted
+/// command; the retail Humanoid table carries zero sequence velocity for Walk
+/// and Run, and retail moves observed characters through the interpolation
+/// queue instead.
///
/// Renamed R5 (was PositionManager): this class is only the
/// InterpolationManager-composition portion of retail's
@@ -33,13 +35,12 @@ public sealed class RemoteMotionCombiner
///
/// Per-frame delta time, seconds.
/// Body's current world-space position.
- ///
- /// Body-local velocity from the active animation cycle
- /// (from AnimationSequencer.CurrentVelocity); pass
- /// Vector3.Zero if the entity has no sequencer or is on a
- /// non-locomotion cycle.
+ ///
+ /// Complete body-local displacement accumulated by this frame's
+ /// CSequence::update. This is already a delta for the current
+ /// quantum, not a velocity to multiply by .
///
- /// Body orientation; used to rotate seqVel from body-local to world.
+ /// Body orientation; used to rotate root motion from body-local to world.
/// The remote's InterpolationManager (for AdjustOffset call).
/// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp.
///
@@ -59,7 +60,7 @@ public sealed class RemoteMotionCombiner
public Vector3 ComputeOffset(
double dt,
Vector3 currentBodyPosition,
- Vector3 seqVel,
+ Vector3 rootMotionLocalDelta,
Quaternion ori,
InterpolationManager interp,
float maxSpeed,
@@ -93,8 +94,7 @@ public sealed class RemoteMotionCombiner
if (correction.LengthSquared() > 0f)
return correction;
- Vector3 rootMotionLocal = seqVel * (float)dt;
- Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
+ Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori);
// Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays
diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs
index 09514a13..1af2ce57 100644
--- a/src/AcDream.Core/World/TeleportAnimSequencer.cs
+++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs
@@ -53,6 +53,7 @@ public sealed class TeleportAnimSequencer
public const int TunnelEndFrame = 120;
public const float ExitWindowLow = FadeTime + 0.1f;
public const float ExitWindowHigh = FadeTime + 0.3f;
+ private const short LastVisibleOutgoingAnimationLevel = 1022;
// UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine
// samples into a 0..1024 easing table.
@@ -119,7 +120,7 @@ public sealed class TeleportAnimSequencer
switch (_state)
{
case TeleportAnimState.WorldFadeOut:
- if (_elapsed >= FadeTime)
+ if (OutgoingViewportReachedTerminalProjection())
// UseTime's viewport-visibility block precedes this state
// transition, so retail shows portal space on the next tick.
Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: true);
@@ -157,7 +158,7 @@ public sealed class TeleportAnimSequencer
break;
case TeleportAnimState.TunnelFadeOut:
- if (_elapsed >= FadeTime)
+ if (OutgoingViewportReachedTerminalProjection())
{
Advance(TeleportAnimState.WorldFadeIn, enterTunnel: false);
evts.Add(TeleportAnimEvent.PlayExitSound);
@@ -180,6 +181,17 @@ public sealed class TeleportAnimSequencer
return (BuildSnapshot(), evts);
}
+ ///
+ /// Retire an outgoing viewport before the first quantized projection that
+ /// exposes the finite tunnel boundary. Retail's whole-frame black clear and
+ /// frame-paced D3D presentation show level 1022 as the last tunnel sample;
+ /// paired captures show no 1023/1024 portal frame before the world swap.
+ /// An uncapped modern loop can otherwise publish those sub-20 ms samples
+ /// and let the desktop compositor hold one for a complete display refresh.
+ ///
+ private bool OutgoingViewportReachedTerminalProjection()
+ => GetRetailAnimationLevel(_elapsed / FadeTime) > LastVisibleOutgoingAnimationLevel;
+
private void Advance(TeleportAnimState next, bool enterTunnel)
{
_state = next;
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs
new file mode 100644
index 00000000..2dfa18fa
--- /dev/null
+++ b/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs
@@ -0,0 +1,51 @@
+using System.Buffers.Binary;
+using AcDream.App.Input;
+using AcDream.App.Rendering;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Net.Packets;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Input;
+
+public sealed class LocalPlayerOutboundCombatStyleTests
+{
+ [Theory]
+ [InlineData(0x8000003Cu)] // HandCombat
+ [InlineData(0x8000003Eu)] // Magic
+ [InlineData(0x8000003Fu)] // BowCombat
+ [InlineData(0x80000041u)] // CrossbowCombat
+ public void CaptureAndBuild_PreservesCanonicalRawCombatStyle(uint combatStyle)
+ {
+ var controller = new PlayerMovementController(new PhysicsEngine());
+ controller.Motion.RawState.CurrentStyle = combatStyle;
+
+ MovementResult movement = controller.CaptureMovementResult(
+ mouseLookEvent: false);
+ RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
+ movement);
+
+ Assert.Equal(combatStyle, movement.CurrentStyle);
+ Assert.Equal(combatStyle, outbound.CurrentStyle);
+
+ var writer = new PacketWriter(16);
+ RawMotionStatePacker.Pack(writer, outbound);
+ byte[] bytes = writer.ToArray();
+
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(bytes);
+ Assert.NotEqual(0u, flags & 0x2u);
+ Assert.Equal(
+ combatStyle,
+ BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)));
+ }
+
+ [Fact]
+ public void CaptureAndBuild_NonCombatRetainsRetailDefault()
+ {
+ var controller = new PlayerMovementController(new PhysicsEngine());
+
+ RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
+ controller.CaptureMovementResult(mouseLookEvent: false));
+
+ Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
index e567e81e..20387dda 100644
--- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs
@@ -12,6 +12,17 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PortalTunnelAssetTests
{
+ [Fact]
+ public void PortalViewport_PreservesColorWhileClearingDepthLikeRetail()
+ {
+ Assert.Equal(
+ Silk.NET.OpenGL.ClearBufferMask.DepthBufferBit,
+ PortalTunnelPresentation.RetailViewportClearMask);
+ Assert.False(
+ (PortalTunnelPresentation.RetailViewportClearMask
+ & Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0);
+ }
+
[Fact]
public void InstalledDat_ResolvesRetailPortalSetupAndAnimation()
{
@@ -261,7 +272,7 @@ public sealed class PortalTunnelAssetTests
[InlineData(45f)]
[InlineData(60f)]
[InlineData(90f)]
- public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfViewAndNearPlane(float degrees)
+ public void PortalTunnelCamera_UsesActiveSmartBoxProjectionClipRange(float degrees)
{
float expected = degrees * (MathF.PI / 180f);
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
@@ -275,6 +286,11 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(expected, camera.FovRadians, precision: 5);
Assert.Equal(0.35f, camera.Near, precision: 5);
+ // Recovering a far plane from a float projection matrix loses a
+ // small amount of precision because M33 is extremely close to -1.
+ // The presentation must nevertheless inherit the active SmartBox
+ // range rather than fall back to its former private 50 m clip.
+ Assert.InRange(camera.Far, 4995f, 5005f);
}
private static string? ResolveDatDir()
diff --git a/tests/AcDream.App.Tests/Rendering/SkyProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/SkyProjectionTests.cs
new file mode 100644
index 00000000..d96c5ece
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/SkyProjectionTests.cs
@@ -0,0 +1,52 @@
+using System.Numerics;
+using AcDream.App.Rendering.Sky;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class SkyProjectionTests
+{
+ [Theory]
+ [InlineData(60f)]
+ [InlineData(120f)]
+ [InlineData(179.885f)]
+ public void WithDepthRange_PreservesActiveFieldOfView(float degrees)
+ {
+ Matrix4x4 active = Matrix4x4.CreatePerspectiveFieldOfView(
+ degrees * MathF.PI / 180f,
+ 16f / 9f,
+ 0.1f,
+ 5_000f);
+
+ Matrix4x4 sky = SkyProjection.WithDepthRange(active, 0.1f, 1_000_000f);
+
+ Assert.Equal(active.M11, sky.M11);
+ Assert.Equal(active.M22, sky.M22);
+ Assert.Equal(active.M34, sky.M34);
+ Assert.Equal(active.M44, sky.M44);
+ Assert.Equal(1_000_000f / (0.1f - 1_000_000f), sky.M33, precision: 6);
+ Assert.Equal(0.1f * 1_000_000f / (0.1f - 1_000_000f), sky.M43, precision: 6);
+ }
+
+ [Fact]
+ public void WithDepthRange_PreservesOffCenterProjectionTerms()
+ {
+ Matrix4x4 active = Matrix4x4.CreatePerspectiveOffCenter(
+ -0.8f, 1.2f, -0.5f, 0.7f, 0.1f, 5_000f);
+
+ Matrix4x4 sky = SkyProjection.WithDepthRange(active, 1f, 10_000f);
+
+ Assert.Equal(active.M11, sky.M11);
+ Assert.Equal(active.M22, sky.M22);
+ Assert.Equal(active.M31, sky.M31);
+ Assert.Equal(active.M32, sky.M32);
+ }
+
+ [Fact]
+ public void WithDepthRange_RejectsNonPerspectiveProjection()
+ {
+ Matrix4x4 orthographic = Matrix4x4.CreateOrthographic(10f, 10f, 0.1f, 100f);
+
+ Assert.Throws(() =>
+ SkyProjection.WithDepthRange(orthographic, 0.1f, 1_000f));
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
index b9bcefd6..d3ec1a51 100644
--- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs
@@ -176,4 +176,32 @@ public sealed class RetailUiAutomationProbeTests
File.Delete(path);
}
}
+
+ [Fact]
+ public void ScriptRunner_command_routesThroughInjectedClientSubmitPath()
+ {
+ var (root, _, _, _, objects) = RootWithTwoItemLists();
+ var probe = new RetailUiAutomationProbe(root, objects);
+ var submitted = new List();
+ string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
+ File.WriteAllText(path, "command /ls");
+
+ try
+ {
+ var runner = new RetailUiAutomationScriptRunner(
+ probe,
+ path,
+ dumpOnStart: false,
+ submitCommand: submitted.Add);
+
+ runner.Tick(0.016);
+
+ Assert.Equal(["/ls"], submitted);
+ Assert.True(runner.Completed);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
}
diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
index fb9f7386..3829f543 100644
--- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs
@@ -149,6 +149,65 @@ public class PlayerMovementControllerTests
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
+ [Fact]
+ public void Update_AttachedAnimationWithZeroRootDelta_DoesNotGlideOnForwardEdge()
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ var start = new Vector3(96f, 96f, 50f);
+ controller.SetPosition(start, 0x0001);
+ controller.Yaw = 0f;
+ controller.AttachAnimationRootMotionSource(_ => Vector3.Zero);
+
+ MovementResult result = controller.Update(
+ PhysicsBody.MinQuantum,
+ new MovementInput(Forward: true));
+
+ Assert.Equal(start, result.Position);
+ Assert.Equal(0f, controller.BodyVelocity.X);
+ Assert.Equal(0f, controller.BodyVelocity.Y);
+ }
+
+ [Fact]
+ public void Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale()
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ var start = new Vector3(96f, 96f, 50f);
+ controller.SetPosition(start, 0x0001);
+ controller.Yaw = 0f;
+ controller.ObjectScale = 2f;
+ controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.1f, 0f));
+
+ MovementResult result = controller.Update(
+ PhysicsBody.MinQuantum,
+ new MovementInput(Forward: true));
+
+ // Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
+ // the animation-authored 0.1 m displacement to 0.2 m.
+ Assert.Equal(start.X + 0.2f, result.Position.X, precision: 3);
+ Assert.Equal(start.Y, result.Position.Y, precision: 3);
+ }
+
+ [Fact]
+ public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep()
+ {
+ var controller = new PlayerMovementController(MakeFlatEngine());
+ var start = new Vector3(96f, 96f, 50f);
+ controller.SetPosition(start, 0x0001);
+ controller.Yaw = 0f;
+ controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.05f, 0f));
+
+ MovementResult first = controller.Update(
+ PhysicsBody.MinQuantum * 0.5f,
+ new MovementInput(Forward: true));
+ MovementResult second = controller.Update(
+ PhysicsBody.MinQuantum * 0.5f,
+ new MovementInput(Forward: true));
+
+ Assert.Equal(start, first.Position);
+ Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
+ Assert.Equal(start.Y, second.Position.Y, precision: 3);
+ }
+
[Fact]
public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition()
{
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
index 1af00c11..53db8f14 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
@@ -298,10 +298,10 @@ public sealed class AnimationSequencerCutoverTraceTests
// POST-adjust_motion substate (45000005, was 45000006) and
// CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns
// the state and retail's interpreted state is post-adjustment. The
- // om=(-0.00,...) is IEEE negative zero from add_motion's
- // zero-omega x negative-speed multiply (numerically equal to 0).
+ // vel/om=(-0.00,...) are IEEE negative zero from add_motion's
+ // zero-components x negative-speed multiply (numerically equal to 0).
Assert.Equal(
- "105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
+ "105@-19.5^,101@-19.5* | frame=3.0 vel=(-0.00,-2.03,-0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
Describe(seq, loader));
}
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
index 0d3af817..6de50f31 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
@@ -1220,6 +1220,29 @@ public sealed class AnimationSequencerTests
Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity);
}
+ [Fact]
+ public void CurrentVelocity_ZeroLocomotionMotionData_IsNotSynthesizedFromCommand()
+ {
+ // Retail add_motion @ 0x005224B0 writes MotionData.Velocity * speed
+ // unconditionally. It never substitutes CMotionInterp's body-speed
+ // constants into CSequence. The installed Humanoid table uses this
+ // zero-velocity shape for both Walk and Run.
+ const uint Style = 0x8000003Du;
+ const uint Motion = 0x40000007u;
+ const uint AnimId = 0x03000406u;
+
+ var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
+ var setup = Fixtures.MakeSetup(1);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion, speedMod: 2f);
+
+ Assert.Equal(Vector3.Zero, seq.CurrentVelocity);
+ }
+
[Fact]
public void CurrentVelocity_ScaledBySpeedMod()
{
@@ -1454,8 +1477,9 @@ public sealed class AnimationSequencerTests
{
// A RunForward motion with MotionData.Velocity = (0,4,0) should
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
- // 0.5×. The dead-reckoning integrator in TickAnimations reads
- // CurrentVelocity each tick, so this has to be accurate.
+ // 0.5×.
+ // CSequence stores that literal DAT velocity for retail
+ // get_state_velocity consumers, so scaling must remain exact.
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
diff --git a/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs
new file mode 100644
index 00000000..48856116
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs
@@ -0,0 +1,77 @@
+using AcDream.Content.Vfx;
+using AcDream.Core.Physics;
+using AcDream.Core.Tests.Conformance;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Pins the retail Humanoid motion table data consumed by CSequence root motion.
+/// The packet-driven remote mover must use these values, not a velocity guessed
+/// from the interpreted command.
+///
+public sealed class HumanoidMotionTableRootMotionTests
+{
+ private const uint HumanoidMotionTable = 0x09000001u;
+ private const uint HumanoidSetup = 0x02000001u;
+ private const uint NonCombatStyle = 0x8000003Du;
+ private const uint WalkForward = 0x40000005u;
+ private const uint RunForward = 0x40000007u;
+ private const uint Ready = 0x41000003u;
+ private const uint InterpretedWalkForward = 0x45000005u;
+
+ [Theory]
+ [InlineData(WalkForward)]
+ [InlineData(RunForward)]
+ public void RetailLocomotionCycle_ExposesItsLiteralMotionDataVelocity(uint motion)
+ {
+ string? datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ return;
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ MotionTable table = Assert.IsType(dats.Get(HumanoidMotionTable));
+ int key = unchecked((int)((NonCombatStyle << 16) | (motion & 0x00FF_FFFFu)));
+ MotionData cycle = Assert.IsType(table.Cycles[key]);
+
+ Assert.Equal(System.Numerics.Vector3.Zero, cycle.Velocity);
+ }
+
+ [Fact]
+ public void RetailWalkSequence_ProducesAuthoredRootDisplacement()
+ {
+ string? datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ return;
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ Setup setup = Assert.IsType(dats.Get(HumanoidSetup));
+ MotionTable table = Assert.IsType(dats.Get(HumanoidMotionTable));
+ var sequencer = new AnimationSequencer(
+ setup,
+ table,
+ new RetailAnimationLoader(dats));
+
+ sequencer.SetCycle(NonCombatStyle, Ready);
+ sequencer.SetCycle(NonCombatStyle, InterpretedWalkForward);
+
+ float distance = 0f;
+ for (int i = 0; i < 120; i++)
+ {
+ var root = new Frame
+ {
+ Origin = System.Numerics.Vector3.Zero,
+ Orientation = System.Numerics.Quaternion.Identity,
+ };
+ sequencer.Advance(1f / 60f, root);
+ distance += root.Origin.Length();
+ }
+
+ Assert.True(
+ distance > 1f,
+ $"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s.");
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs
index 192927a6..4f039c6b 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs
@@ -94,8 +94,9 @@ public class MotionTableDispatchSinkTests
Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal(2.0f, seq.CurrentSpeedMod);
- // Locomotion velocity synthesis ran in the passthrough (AP-75).
- Assert.Equal(MotionInterpreter.WalkAnimSpeed * 2.0f, seq.CurrentVelocity.Y, 3);
+ // add_motion preserves the literal zero MotionData velocity; body
+ // speed belongs to CMotionInterp, not CSequence.
+ Assert.Equal(Vector3.Zero, seq.CurrentVelocity);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs
index e6d4300f..3fb90ad1 100644
--- a/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs
@@ -10,7 +10,7 @@ namespace AcDream.Core.Tests.Physics;
// from PositionManager; see RemoteMotionCombiner's class doc).
//
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
-// Pure-function combiner: animation root motion (seqVel × dt, rotated by
+// Pure-function combiner: CSequence root-motion delta (rotated by
// body orientation) + InterpolationManager.AdjustOffset correction.
// ─────────────────────────────────────────────────────────────────────────────
@@ -35,7 +35,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: Vector3.Zero,
+ rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@@ -53,11 +53,11 @@ public sealed class RemoteMotionCombinerTests
var pm = Make();
var interp = EmptyInterp();
- // seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0)
+ // CSequence accumulated (0, 0.4, 0) for this 0.1-second quantum.
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(0f, 4f, 0f),
+ rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f);
@@ -83,7 +83,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(0f, 4f, 0f),
+ rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: ori,
interp: interp,
maxSpeed: 0f);
@@ -110,7 +110,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: Vector3.Zero,
+ rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@@ -146,7 +146,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(0f, 4f, 0f),
+ rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@@ -170,12 +170,12 @@ public sealed class RemoteMotionCombinerTests
// body-local +Y → world -X
Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
- // seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0)
+ // CSequence accumulated rootMotionLocal = (0, 1, 0).
// after Transform by ori → (-1, 0, 0) approximately
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(0f, 1f, 0f),
+ rootMotionLocalDelta: new Vector3(0f, 1f, 0f),
ori: ori,
interp: interp,
maxSpeed: 0f);
@@ -196,7 +196,7 @@ public sealed class RemoteMotionCombinerTests
// =========================================================================
[Fact]
- public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
+ public void ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
{
var pm = Make();
var interp = EmptyInterp(); // queue empty → fallback path runs
@@ -213,7 +213,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(4f, 0f, 0f),
+ rootMotionLocalDelta: new Vector3(4f, 0f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
@@ -225,7 +225,7 @@ public sealed class RemoteMotionCombinerTests
}
[Fact]
- public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
+ public void ComputeOffset_RootMotionFallback_FlatTerrainNormal_NoZChange()
{
var pm = Make();
var interp = EmptyInterp();
@@ -234,7 +234,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
- seqVel: new Vector3(0f, 4f, 0f),
+ rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
@@ -244,4 +244,33 @@ public sealed class RemoteMotionCombinerTests
Assert.Equal(0.4f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4);
}
+
+ [Fact]
+ public void ComputeOffset_QueueHeadReached_WithLiteralZeroRootMotion_DoesNotOvershoot()
+ {
+ var pm = Make();
+ var interp = new InterpolationManager();
+ var target = new Vector3(0.4f, 0f, 0f);
+ interp.Enqueue(target, heading: 0f, isMovingTo: false);
+
+ Vector3 catchUp = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ rootMotionLocalDelta: Vector3.Zero,
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 4f);
+ Vector3 reachedPosition = catchUp;
+
+ Vector3 afterReach = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: reachedPosition,
+ rootMotionLocalDelta: Vector3.Zero,
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 4f);
+
+ Assert.Equal(target, reachedPosition);
+ Assert.Equal(Vector3.Zero, afterReach);
+ }
}
diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
index 2a6e1a88..ef0820d9 100644
--- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
@@ -262,6 +262,70 @@ public sealed class TeleportAnimSequencerTests
Assert.Equal(expected, TeleportAnimSequencer.GetRetailAnimationLevel(t));
}
+ [Theory]
+ [InlineData(240f)]
+ [InlineData(700f)]
+ [InlineData(2000f)]
+ public void WorldFadeOut_DoesNotPublishTerminalProjectionOnOutgoingWorld(float framesPerSecond)
+ {
+ var seq = new TeleportAnimSequencer();
+ seq.Begin(TeleportEntryKind.Logout);
+ seq.Tick(0f, worldReady: false);
+
+ AssertOutgoingViewportRetiresBeforeTerminal(
+ seq,
+ TeleportAnimState.WorldFadeOut,
+ framesPerSecond);
+ }
+
+ [Theory]
+ [InlineData(240f)]
+ [InlineData(700f)]
+ [InlineData(2000f)]
+ public void TunnelFadeOut_DoesNotPublishTerminalProjectionOnFiniteTunnel(float framesPerSecond)
+ {
+ var seq = new TeleportAnimSequencer();
+ seq.Begin(TeleportEntryKind.Portal);
+ seq.Tick(0f, worldReady: false);
+ seq.Tick(0.016f, worldReady: true);
+ DriveSeconds(
+ seq,
+ TeleportAnimSequencer.MinContinue + 0.05f,
+ worldReady: true,
+ step: 1f / framesPerSecond);
+ Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
+
+ AssertOutgoingViewportRetiresBeforeTerminal(
+ seq,
+ TeleportAnimState.TunnelFadeOut,
+ framesPerSecond);
+ }
+
+ private static void AssertOutgoingViewportRetiresBeforeTerminal(
+ TeleportAnimSequencer seq,
+ TeleportAnimState outgoingState,
+ float framesPerSecond)
+ {
+ float dt = 1f / framesPerSecond;
+ bool transitioned = false;
+ for (int i = 0; i < (int)MathF.Ceiling(framesPerSecond * 1.1f); i++)
+ {
+ var (snapshot, _) = seq.Tick(dt, worldReady: true);
+ if (snapshot.State == outgoingState)
+ {
+ Assert.True(
+ snapshot.ViewPlaneBlend <= 1022f / 1024f,
+ $"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}.");
+ continue;
+ }
+
+ transitioned = true;
+ break;
+ }
+
+ Assert.True(transitioned, "The outgoing viewport did not retire.");
+ }
+
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
[Fact]
@@ -314,14 +378,14 @@ public sealed class TeleportAnimSequencerTests
}
[Fact]
- public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
+ public void ViewPlaneBlend_ReachesLastVisibleLevel_BeforeWorldFadeOutRetires()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
- DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
+ DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false);
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
@@ -329,6 +393,7 @@ public sealed class TeleportAnimSequencerTests
Assert.True(
snap.ViewPlaneBlend > 0.95f,
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
+ Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f);
}
[Fact]