fix(runtime): align portal and movement presentation

Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 08:48:27 +02:00
parent e95f55f25b
commit 124e046976
30 changed files with 1362 additions and 348 deletions

View file

@ -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.124.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 L614679 (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` § 57
- `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

View file

@ -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 `|BodyLastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the 96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions `|Body.Position worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body garbage resolved pos the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap 96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
| AP-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 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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);
/// <summary>
/// Portal-space state for the player movement controller.
@ -135,6 +140,13 @@ public sealed class PlayerMovementController
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
/// <summary>
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
/// displacement is multiplied by this value before PositionManager
/// composition (<c>UpdatePositionInternal @ 0x00512C30</c>).
/// </summary>
public float ObjectScale { get; set; } = 1f;
/// <summary>
/// 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<float, Vector3>? _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;
}
/// <summary>
/// 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
/// <c>CSequence::update</c>. The controller accumulates sub-quantum deltas
/// and consumes them through the same PositionManager frame as retail.
/// </summary>
public void AttachAnimationRootMotionSource(Func<float, Vector3> advance)
{
_advanceAnimationRootMotion = advance
?? throw new ArgumentNullException(nameof(advance));
_pendingAnimationRootMotion = Vector3.Zero;
}
/// <summary>
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
/// stamps only <c>last_sent_position_time</c>. 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);
}
/// <summary>
@ -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;
}
/// <summary>
@ -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);
}
/// <summary>

View file

@ -92,9 +92,18 @@ internal sealed class RemotePhysicsUpdater
/// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor).
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
/// the complete local displacement produced by this frame's preceding
/// <c>CSequence::update</c>, matching retail's
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
/// </summary>
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,

View file

@ -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<AcDream.Core.Physics.PartTransform>? PreparedSequenceFrames;
public bool SequenceAdvancedBeforeAnimationPass;
public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
/// <summary>
/// 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();
/// <summary>
/// Per-remote-entity dead-reckoning state for smoothing between server
/// UpdatePosition broadcasts. Without this, remote characters teleport
/// every ~100200 ms when the server pushes a new position (the retail
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
/// velocity forward each tick — see chunk_00520000.c
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
/// 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
/// <c>InterpolationManager::adjust_offset</c> every object tick.
///
/// <para>
/// 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 <c>CurrentVelocity</c> (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.
/// </para>
/// </summary>
private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
@ -684,13 +689,10 @@ public sealed class GameWindow : IDisposable
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
/// previous UpdatePosition's world position + timestamp. The per-tick
/// path computes <c>(serverPos - prevServerPos) / dt</c> and compares
/// it to the sequencer's <c>CurrentVelocity</c>. 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 <c>CSequence::update</c>. The old inferred
/// walk/run velocity was the source of the periodic overshoot this
/// diagnostic was introduced to find.
/// </summary>
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
@ -713,12 +715,11 @@ public sealed class GameWindow : IDisposable
/// </summary>
public double LastPartsDiagLogTime;
/// <summary>
/// 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.
/// </summary>
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.
/// </summary>
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<AcDream.Core.Physics.PartTransform>? 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<AcDream.Core.Physics.PartTransform>? 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<AcDream.Core.Physics.PartTransform>? 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).

View file

@ -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;
/// <summary>
/// Retail <c>CreatureMode::UseSmartboxFOV</c> 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.
/// </summary>
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;
}

View file

@ -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
}
/// <summary>
/// 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.
/// </summary>
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);

View file

@ -0,0 +1,52 @@
using System;
using System.Numerics;
namespace AcDream.App.Rendering.Sky;
/// <summary>
/// Builds the temporary sky projection used by retail <c>GameSky::Draw</c>
/// (<c>0x00506FF0</c>): preserve the active viewport's field of view and
/// projection handedness, changing only its near/far depth mapping.
/// </summary>
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;
}
}

View file

@ -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.

View file

@ -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));
}
}

View file

@ -16,6 +16,7 @@ public sealed class RetailUiAutomationScriptRunner
{
private readonly RetailUiAutomationProbe _probe;
private readonly Action<string> _log;
private readonly Action<string>? _submitCommand;
private readonly List<ScriptCommand> _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<string>? log = null)
Action<string>? log = null,
Action<string>? 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 <chat-or-client-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;

View file

@ -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
/// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>, 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.
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting
/// <see cref="CurrentVelocity"/> remains the literal retail
/// <c>MotionData.Velocity * speed</c> written by <c>add_motion</c>;
/// command-derived body speed belongs to
/// <c>CMotionInterp::get_state_velocity</c>, not to CSequence.
/// </summary>
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);
}
/// <summary>
/// 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
/// <c>add_motion</c>'s unconditional set leaves the sequence at zero —
/// but retail's body physics uses <c>CMotionInterp::get_state_velocity</c>
/// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the
/// dat), so consumers of <see cref="CurrentVelocity"/> 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.
/// </summary>
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

View file

@ -3,20 +3,22 @@ using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// 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 <c>CSequence::update</c> + 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 <c>Frame.Origin</c>
/// accumulated by <c>CPartArray::Update</c>: 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.
///
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
/// InterpolationManager-composition portion of retail's
@ -33,13 +35,12 @@ public sealed class RemoteMotionCombiner
/// </summary>
/// <param name="dt">Per-frame delta time, seconds.</param>
/// <param name="currentBodyPosition">Body's current world-space position.</param>
/// <param name="seqVel">
/// Body-local velocity from the active animation cycle
/// (from <c>AnimationSequencer.CurrentVelocity</c>); pass
/// <c>Vector3.Zero</c> if the entity has no sequencer or is on a
/// non-locomotion cycle.
/// <param name="rootMotionLocalDelta">
/// Complete body-local displacement accumulated by this frame's
/// <c>CSequence::update</c>. This is already a delta for the current
/// quantum, not a velocity to multiply by <paramref name="dt"/>.
/// </param>
/// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param>
/// <param name="ori">Body orientation; used to rotate root motion from body-local to world.</param>
/// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param>
/// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param>
/// <param name="terrainNormal">
@ -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

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
private bool OutgoingViewportReachedTerminalProjection()
=> GetRetailAnimationLevel(_elapsed / FadeTime) > LastVisibleOutgoingAnimationLevel;
private void Advance(TeleportAnimState next, bool enterTunnel)
{
_state = next;

View file

@ -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);
}
}

View file

@ -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()

View file

@ -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<ArgumentException>(() =>
SkyProjection.WithDepthRange(orthographic, 0.1f, 1_000f));
}
}

View file

@ -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>();
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);
}
}
}

View file

@ -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()
{

View file

@ -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));
}

View file

@ -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;

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<MotionTable>(dats.Get<MotionTable>(HumanoidMotionTable));
int key = unchecked((int)((NonCombatStyle << 16) | (motion & 0x00FF_FFFFu)));
MotionData cycle = Assert.IsType<MotionData>(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<Setup>(dats.Get<Setup>(HumanoidSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(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.");
}
}

View file

@ -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]

View file

@ -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);
}
}

View file

@ -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]