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