fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once. User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
e95f55f25b
commit
124e046976
30 changed files with 1362 additions and 348 deletions
190
docs/ISSUES.md
190
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue