diff --git a/CLAUDE.md b/CLAUDE.md index 469b95c3..0e02b6d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,14 +552,17 @@ via `PlayerMovementController.ApplyServerRunRate`) or from diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. -- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an - env-var gate on an experimental per-tick remote motion path. L.3 M2 - (commit 40d88b9) replaced both gates (`OnLivePositionUpdated` + - `TickAnimations`) with `IsPlayerGuid(...)` so player remotes use the - retail-faithful queue routing (InterpolationManager queue catch-up + - PositionManager combiner) unconditionally. NPCs and airborne player - remotes still flow through the legacy `apply_current_movement` + - `ResolveWithTransition` path. The env-var no longer toggles anything. +- ⚠️ `ACDREAM_INTERP_MANAGER=1` — **DO NOT ENABLE.** This was an + experimental rewrite (e94e791) of the per-tick remote motion path. + It's regressed: the env-var path drops the per-tick collision sweep + (`ResolveWithTransition`) that the default path retains, causing a + visible "staircase" pattern when remotes run up/down slopes (body + Z stays flat between UPs, snaps at each one) plus position blips + during steady-state motion. Default (env-var unset) uses the + working retail-port chain. The PositionManager class itself is + fine and retail-faithful; only the integration into per-tick was + wrong. To be re-done in a future L.3 follow-up phase as additive + refinement on top of the working chain. ### Outbound motion wire format (acdream → ACE) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bdce230f..794a37bc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -124,280 +124,7 @@ the same direction. Add a `LastUMUpdateTime` grace window (e.g. - No spurious cycle thrashing during turning while running (ObservedOmega doesn't trigger velocity-bucket changes). -## #42 — Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc) - -**Status:** OPEN -**Severity:** MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix) -**Filed:** 2026-05-05 (root cause confirmed same day) -**Component:** physics (`PhysicsEngine.ResolveWithTransition` airborne behavior) - -**Root cause (verified 2026-05-05 via A/B test):** - -`ResolveWithTransition` running per-tick during the airborne arc is the -source of the drift. Verified by A/B-toggling the M4 CellId fix -(`rmState.CellId = p.LandblockId`) which is the gate that lets the -sweep run for player-remote jumps: - -- **CellId line removed** → sweep skipped → jumps render with - geometrically-correct XY (no drift) but body falls through the - floor (no terrain catch). -- **CellId line present** → sweep runs → jumps land correctly but - arc shows ~1 m horizontal offset from actor's actual XY; body - snaps back on next inbound UM. - -So the drift originates inside `ResolveWithTransition` itself, not -from wire data, not from local Euler integration, not from stale -velocity. Decision recorded in commit history: kept CellId fix in -production code so jumps land (`fall-through-floor` is more disruptive -to gameplay than `~1m visual jitter that resolves on next input`). -This issue tracks the proper fix. - -**Description:** When observing a retail-controlled remote that jumps -in place (no horizontal input), the visible jump arc renders with -a small horizontal offset from the actor's actual position — typically -~1 m to one side and slightly forward. Body lands at offset position -(~X+1m). On the next inbound UM/UP from the actor (e.g., turning or -moving), the body snaps back to the server's authoritative X. - -User report 2026-05-05 (after M4 CellId fix): "I stand at position X -and jump, it looks like im jumping slightly to the left of X like -1m-ish (if I observe jumping char from behind). It also lands at -X + 1m-ish. Position resets to X when I issue some other command -to the client like turning." - -**Why it surfaced now:** - -Pre-M2 (legacy path), `OnLivePositionUpdated` hard-snapped -`rmState.Body.Position = worldPos` on EVERY UP including mid-arc -airborne ones. ACE broadcasts intermediate UPs at ~5–10 Hz during -the jump arc with the actor's authoritative mid-arc position; -each snap kept our local body close to server, masking -local-integration error. - -L.3 M2 (commit 40d88b9) implemented the retail-spec airborne no-op -in `OnLivePositionUpdated`: - -```csharp -if (!update.IsGrounded) { - entity.Position = rmState.Body.Position; - return; -} -``` - -Per `docs/research/2026-05-04-l3-port/03-up-routing.md` § 3: - -> Air branch (`has_contact == 0`): the function falls through to -> `return 0`. This is the "AIRBORNE NO-OP" … The body keeps -> integrating gravity locally; received position is discarded. - -This matches retail `MoveOrTeleport @ 0x00516330` semantics. But it -removes the periodic server snapping that was masking ~1 m of -accumulated local-integration drift. The drift is pre-existing — the -user reports having seen it before — but is now visible for the -full arc duration instead of being corrected every ~200 ms. - -**Likely mechanism (ranked by probability):** - -1. **Initial-overlap depenetration along non-+Z terrain normal** — at - jump start the collision sphere is touching the floor at body Z. - Most outdoor terrain triangles are not perfectly horizontal — their - normals have a small horizontal component. The sweep's first action - each tick is to resolve overlap by separating the sphere along the - contact normal; on a tilted terrain triangle that separation has - horizontal magnitude. The body gets shoved sideways the first frame - of the jump and the rest of the arc carries that initial drift. - Direction-correlation with terrain orientation would confirm - (test in different landblocks; if drift direction varies with the - slope of the launch tile, this is it). - -2. **Step-down probe firing despite `isOnGround: false`** — sweep's - internal "search for nearest walkable surface" might still scan - horizontally during airborne ticks even when we pass `isOnGround: - !rm.Airborne` (= false for airborne). Check whether the - `stepUpHeight` / `stepDownHeight` parameters are unconditionally - used inside `ResolveWithTransition` regardless of the - `isOnGround` flag. - -3. **EdgeSlide on near-vertical motion against a near-vertical - surface** — if the sphere even slightly grazes a wall while - ascending or descending, EdgeSlide projects motion tangent to the - wall, redirecting some Z velocity into XY. Less likely for - open-ground stationary jumps but could explain drift near - buildings. - -**Fix paths:** - -a. **Skip initial-overlap depenetration when airborne** — gate the - "separate from initial contact plane" step inside - `ResolveWithTransition` on `isOnGround: true`. Trusts the previous - tick's resolve to have left the body in a non-overlapping position. - This is the most likely-correct fix if hypothesis (1) is right. - -b. **Zero step-up/down for airborne sweeps** — pass - `stepUpHeight: 0f, stepDownHeight: 0f` when `rm.Airborne`. Kills - hypothesis (2) without other side effects (airborne bodies don't - step anyway). - -c. **Stripped airborne sweep** — replace the full sphere sweep with - a simpler vertical sphere-vs-terrain intersection + wall-collision - stop. Loses some retail fidelity but eliminates all three - mechanisms. Probably overkill if (a) or (b) suffices. - -**Files:** - -- `src/AcDream.Core/Physics/PhysicsEngine.cs` — - `ResolveWithTransition` and any internal `CTransition` / - `find_valid_position` helpers. The initial-overlap depenetration - path is the primary investigation target. -- `src/AcDream.App/Rendering/GameWindow.cs:6478+` (legacy airborne - TickAnimations, the call site) — reference only; not the bug. - -**Reference:** - -Retail equivalent at -`docs/research/named-retail/acclient_2013_pseudo_c.txt`: -- `CTransition::find_valid_position` (called from `transition()`) -- `SpherePath` initialization -- The verbatim retail depenetration logic for airborne bodies - -If our port differs from retail in this region, that diff is likely -the bug. - -**Repro:** - -1. Launch acdream + retail client side-by-side connected to local ACE. -2. Have retail char stand still on outdoor terrain at any position X. -3. Jump in place. -4. Observe acdream window: arc renders ~1 m offset from X, lands - offset, snaps back on next UM. - -To verify the depenetration hypothesis specifically, repeat the jump -in different landblock spots — drift direction should correlate with -the local terrain normal, not the actor's facing. - -**Acceptance:** - -- Visual jump arc + landing render at the actor's actual XY position, - no perceptible horizontal offset, no snap-back on next UM. -- Wall-collision airborne (jumping into building doorways, jumping - puzzles) still works — fix must not strip collision wholesale. - ---- - -## #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) -**Filed:** 2026-05-05 -**Component:** physics / motion / animation (per-tick remote prediction) - -**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." - -The blips fall well within retail's own tolerances: - -- `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. - -**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 - -**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`. - -**Acceptance:** - -- Visual blips disappear on flat-ground steady-state running. -- Side-by-side acdream-as-observer vs retail-as-observer of the same - server-controlled toon: indistinguishable body trajectory. - ---- - -## #40 — [DONE 2026-05-05 · 40d88b9] ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) - -**Status:** DONE — closed by L.3 M2 (`feat(motion): L.3 M2 — queue-only chase for grounded player remotes`, commit 40d88b9) - -**Resolution:** The env-var gate was retired entirely. Both -`OnLivePositionUpdated` and `TickAnimations` now use -`IsPlayerGuid(serverGuid)` to route player-remote UPs through the -retail-faithful queue path (formerly the env-var path, but with two -key fixes per the L.3 spec): - -1. `PositionManager.ComputeOffset` is the per-tick translation source - (REPLACE semantics: queue catch-up overrides anim root motion when - active, anim stands when queue is idle / head reached). Mirrors - retail `UpdatePositionInternal @ 0x00512c30`. -2. `ResolveWithTransition` is **not** called for grounded player - remotes — server already collision-resolved the broadcast position, - and sweeping per-tick on tiny queue catch-up deltas amplified - micro-bounces into visible blips. This was the staircase + blip - regression. Trade-off documented in audit § 6. - -User-verified 2026-05-05: smooth body chase, no staircase on slopes, -no per-UP rubber-band on flat ground. Residual sub-decimeter blips -filed separately as #41 (velocity-synthesis magnitude). - -**Filed-original-context (for archive):** +## #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips) **Status:** OPEN (do-not-enable; pending L.3 follow-up rebuild) **Severity:** N/A (gated; default behavior unaffected) @@ -583,35 +310,22 @@ What we confirmed (data is correct): matches ACME's `StaticObjectManager.cs:256-258` and retail decomp's `Frame::combine` at `0x00518FD0`. -**Investigation 2 (2026-05-04, 5 parallel agents + dat probes):** +**Remaining hypothesis space (untested):** -ALL of the obvious hypotheses ruled out: - -- **Byte-level decode primitive matches ACViewer.** INDEX16/P8/DXT/BGRA paths are byte-identical. -- **Polygon emission matches retail.** All 43 polygons of gfx `0x0100120D` are `SidesType=0` (ST_SINGLE), all surfaces are `Base1Image` — NO ST_DOUBLE polygons we'd be missing, NO surfaces lacking the `Type & 6` bits that retail's `DrawPolyInternal` skips. -- **Per-PART texture-override scoping is correct.** `resolvedOverridesByPart[partIdx]` gets per-MeshRef'd; not a global flat map (Agent 3's claim was wrong). -- **SubPalettes are full-size (Colors.Count=2048) palettes.** Our `subPal.Colors[idx]` indexing matches ACViewer's `newPalette.Colors[j + offset]`. -- **The `*8` wire un-pack is correctly single-applied** (parser stores raw bytes; ComposePalette multiplies once). - -**The actual smoking gun (Investigation 2):** - -For `+Acdream` the server sends 10 SubPaletteSwap ranges that overlay palette indices: -`[0..320)`, `[576..1024)`, `[1392..1488)`, `[1728..1920)`. **The complement — indices `[320..576)`, `[1024..1392)`, `[1488..1728)`, `[1920..2048)` — is NOT overlaid.** Base palette `0x0400007E` at those indices contains the original red/skin tones (sampled values: `0x46 0x22 0x04`, `0x4A 0x28 0x09`, etc). - -If the coat texture's UVs at the upper region map to texel-bytes whose palette index lands in one of those non-overlaid ranges, those pixels render with base-palette skin tones. That's the visible "skin stub at the top of the coat". - -**Working hypothesis:** either -1. ACE sends incomplete SubPalette ranges (retail-original would cover the full palette) -2. Retail does *additional* client-side compute that ACE pre-resolves wrongly -3. The base palette `0x0400007E` itself is supposed to have coat colors at those indices in retail's interpretation (different palette decode) - -**Next investigation (deferred):** - -- Diff ACE's `WorldObject_Networking.cs` CharGen ObjDesc construction against retail's - `ClothingTable::BuildObjDesc` (`acclient_2013_pseudo_c.txt:436261`). Check if ACE - actually walks every CloSubPaletteRange in the chosen PaletteTemplate, or skips some. -- RenderDoc capture: confirm which texel/palette-index the upper-region polygons sample. -- `tools/InspectCoatTex/Program.cs` is the diagnostic harness — extend it. +1. **Texture decode produces skin pixels** for `0x05001AFE/0x05001AFC` + where ACME / retail produces coat pixels. Compare our SurfaceDecoder + against ACME's `TextureHelpers.cs` for INDEX16 / palette-indexed + chains. +2. **Polygon-to-surface mapping off-by-one.** Specific polygons of + part 9 reference an unintended surface. Add a dump: for each polygon + in gfx 0x0100120D, print `PosSurface` index + the resolved Surface id. +3. **Multi-layer texture composition retail does and we skip.** AC's + "ApplyCloth" or similar layered texture step. Grep + `acclient_2013_pseudo_c.txt` for `BlendBaseLayer`, `LayerSurfaces`, + any composition method that combines multiple textures into one. +4. **UV mapping bug.** Part 9's polygon UVs map to a skin region of + the coat texture. Dump per-vertex UV vs vertex Z; if a high-Z vertex + has UV.v near a skin region, that's the source. **Files (diagnostic env vars committed for next-session reuse):** diff --git a/docs/research/2026-05-04-l3-port/00-master-plan.md b/docs/research/2026-05-04-l3-port/00-master-plan.md deleted file mode 100644 index 7c6d470b..00000000 --- a/docs/research/2026-05-04-l3-port/00-master-plan.md +++ /dev/null @@ -1,150 +0,0 @@ -# Full motion port — master plan - -**Date:** 2026-05-04 -**Scope:** Every motion-related code path. No bandaids, no workarounds. -**Source research:** all 14 docs in this directory. - ---- - -## Critical reconciliation (research-vs-research) - -Two prior research findings appeared to conflict: - -- **Agent 02** (UM handling): "Retail does NOT velocity-dead-reckon walking remotes. m_velocityVector stays at zero." -- **Agent 09** (CPartArray): "Retail's actual locomotion comes from `CMotionInterp::get_state_velocity` → `CPhysicsObj::set_velocity` → `CTransition::transitional_insert`, NOT CSequence." -- **Agent 11** (UM dispatch deep): "`CPhysicsObj::DoInterpretedMotion` both pushes velocity (`set_local_velocity(get_state_velocity())`) AND drives the animation sequencer's per-axis cycle slot." - -**Reconciliation:** Agent 02 was wrong. `m_velocityVector` IS non-zero for walking remotes — it gets written by `set_local_velocity` inside `DoInterpretedMotion`, which fires per-axis when an UM arrives AND on every per-tick `apply_current_movement` call. The original cdb trace agent 02 cited (`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`) likely sampled at a moment when `DoInterpretedMotion` had not yet fired (e.g., immediately post-spawn). - -**Correct retail model for walking remote per-tick:** - -``` -1. Frame local = identity // var_40 cached -2. CPartArray::Update writes animation root motion into local - - Humanoid locomotion (Walk/Run/Sidestep): dat ships zero, so local.origin stays 0 - - Non-locomotion cycles with baked PosFrames: local.origin = baked delta -3. PositionManager::adjust_offset: - - InterpolationManager::adjust_offset: when distance(body, head) >= 0.05m, - OVERWRITES local.origin with `direction × min(catchUpSpeed × dt, distance)` - (catch-up REPLACES animation root motion this frame) - - StickyManager::adjust_offset: when stuck, writes XY step + heading toward target - - ConstraintManager::adjust_offset: when leashed and grounded, scales/zeros local.origin -4. Frame::combine(out, m_position.frame, local) - - out.origin = m_position.origin + rotate(local.origin by m_position.l2gv) - - out.rotation = m_position.rotation · local.rotation -5. UpdatePhysicsInternal: gated on velocity² > 0 - - out.origin += m_velocityVector × dt + 0.5 × accel × dt² - - For grounded walking remote: m_velocityVector = walk_pace_world (set per-tick by apply_current_movement → set_local_velocity → get_state_velocity). NON-ZERO. - - For idle (Ready cycle): get_state_velocity returns 0 → m_velocityVector = 0 → no-op. -6. transition() — sphere sweep from m_position to out -7. set_frame(resolved) — commit -``` - -**Implication for L.3 port already shipped (commit 91bf1e0):** - -The L.3 commit ZEROS `body.Velocity` for player remotes and removes `apply_current_movement` from per-tick. This is **wrong direction**. It causes: -- Body advances only via queue catch-up (no velocity integration). Steady run lags. -- Orientation changes don't propagate (since velocity isn't recomputed per-tick to use latest orientation). Drift during turning. -- Stop case is fine (cycle goes Ready → seqVel=0 → no motion) but drift case is broken. - -**Action:** revert the body.Velocity=0 change. Restore per-tick `apply_current_movement` for all remotes. Layer queue catch-up as ADDITIONAL local-frame contribution that REPLACES animation root motion (which is 0 for locomotion anyway). Sequence is exactly as agent 01 documented. - ---- - -## What needs porting (consolidated) - -### Phase A: Per-tick pipeline correction (Critical — fixes L.3 regression + drift) - -1. **Restore per-tick `apply_current_movement` for all grounded remotes.** Remove the `IsPlayerGuid` zero-velocity branch added in commit 91bf1e0. -2. **Layer InterpolationManager catch-up before Frame::combine.** Local frame = identity, optionally overwritten by adjust_offset. Then composed with m_position. Then velocity integrates additively into the composed frame. -3. **Implement `Frame::combine` semantics correctly.** Out.origin = m_position.origin + rotate(local.origin by m_position.l2gv basis). Out.rotation = m_position.rotation · local.rotation. - -### Phase B: Substepping (eliminates ObservedOmega) - -4. **Port full `update_object` substepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum.** Constants: 0.0002s / (1/30)s / 0.1s / 2.0s. -5. **Eliminate manual `ObservedOmega` integration.** Once substepping works, retail's `omega` field on Body is integrated correctly per substep — no need for the formula seed `(π/2 × turnSpeed)`. -6. **Drop `RemoteMotion.ObservedOmega` field.** - -### Phase C: UM handling rewrite (collapses 600-line cycle picker) - -7. **Port `apply_interpreted_movement` (acclient @ 0x00528600).** Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue. -8. **Port `MotionTableManager`, `CMotionTable`, `MotionState`.** Each remote has its own (style, substate, substate_mod, modifier_head, action_head). Cycle decision per-axis through table lookups, not priority picker. -9. **Port `GetObjectSequence` (@ 0x00522860).** Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore). -10. **Collapse `OnLiveMotionUpdated` to `move_to_interpreted_state(wireInterpretedState)`.** Drop the 600-line cycle picker (forward → sidestep → turn → Ready priority). Drop the RunForward → WalkForward → Ready fallback chain (acdream-original, doesn't exist in retail). -11. **Port `adjust_motion` (FUN_00528010).** WalkBackward → WalkForward + speed×-0.65; SideStepLeft → SideStepRight + speed×-1. This is sender-side normalization. Local player path needs it too (drops hand-rolled overrides at PlayerMovementController.cs:378-411). - -### Phase D: UP routing — full Branch A (hard teleport) - -12. **Plumb `TeleportSequence` (u16) from `UpdatePosition` parser through `EntityPositionUpdate` to `OnLivePositionUpdated`.** Currently dropped at WorldSession.cs:110-114, 712-716. -13. **Port `update_times[]` array on RemoteMotion.** 4 slots: instance(8), position(0), teleport(4), force_position(6). Each is `u16` with 16-bit-wrap forward-test via `newer_event` helper. -14. **Implement Branch A:** when `newer_event(TELEPORT_TS, wire.teleport_seq)` OR `cell == 0` → call `teleport_hook` (CancelMoveTo + UnStick + StopInterpolating + UnConstrain + ClearTarget + report_collision_end) + `SetPosition` with flags 0x1012. -15. **Implement Branch B (queue) and Branch C (slide-snap)** correctly — these are ALREADY in commit 91bf1e0 modulo using `player_distance` (already fixed via S1). Keep. - -### Phase E: VectorUpdate (jump) corrections - -16. **Drop `OnLiveVectorUpdated`'s extra writes** (Gravity flag toggle, transient_state Contact/OnWalkable clear). Retail's `DoVectorUpdate` only writes `set_velocity` + `set_omega`. The Gravity flag transition is driven by `set_on_walkable(false)` 1→0 edge inside the per-tick path (post-LeaveGround). -17. **Port `set_on_walkable` 0→1 / 1→0 edge detection.** Drives LeaveGround/HitGround automatically without explicit Airborne flag. -18. **Drop `RemoteMotion.Airborne` bool.** Replace with `(state & Gravity) != 0` test. -19. **Replace K-fix15 landing heuristic** (Z-velocity-based) with retail's `contact_plane.N.z >= floor_z` (cosine of 49°) gate. -20. **Port `jumped_this_frame` write in `set_velocity`.** Currently missing in PhysicsBody. - -### Phase F: Sticky + Constraint (port from scratch) - -21. **Port `StickyManager` (acclient ~0x???)** — sticks body to target object at fixed cylinder radius. Activates via MoveToManager when `__inner0` bit 0x80 set. adjust_offset writes XY step + heading rotation; Z always zero. -22. **Port `ConstraintManager`** — leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it. -23. **Wire both into `PositionManager.adjust_offset` chain** in fixed order: Interp → Sticky → Constraint. - -### Phase G: NPC convergence - -24. **Drop `RemoteMotion.HasServerVelocity` / `ServerVelocity` synth machinery.** NPCs go through same per-tick pipeline as player remotes once Phase A lands. -25. **`RemoteMoveToDriver` audit:** add UseFinalHeading (bit 0x40 post-arrival rotation), Sticky-after-arrival (bit 0x80 latch-on), Contact-bit gating during mid-air. Per agent 07 research §"3 minor port gaps". - -### Phase H: Local player cleanup (low-priority) - -26. **Wire `PlayerDescription` (0xF7B0 / 0x0013) → `_weenie.SetSkills(...)`.** Drops `ACDREAM_RUN_SKILL` env var workaround (issue #7). -27. **Gate `AutonomousPosition` heartbeat on motion.** Don't send 1Hz heartbeat at rest (cdb confirmed retail doesn't). -28. **Apply `adjust_motion` to local player input.** Drops hand-rolled velocity overrides at `PlayerMovementController.cs:378-411` and `:466-501`. - -### Phase I: Cleanup - -29. **Delete env-var fork** (`ACDREAM_INTERP_MANAGER=1` path, ~430 lines of dead code). -30. **Remove `RemoteMotion` workaround fields:** `ServerVelocity`, `HasServerVelocity`, `LastServerZ`, `PrevServerPos*`, `Max*`, `TargetOrientation`, `ObservedOmega`. Down from 31 fields to ~10. -31. **Remove all `IsPlayerGuid` per-tick gates** (5 sites in audit). Same pipeline for all entities. -32. **Remove `ServerControlledLocomotion.PlanFromVelocity`** if `ApplyServerControlledVelocityCycle` is the only caller (already removed in commit 91bf1e0). Verify other consumers. - -### Phase J: Tests + verification - -33. **Update existing tests for new architecture.** InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement. -34. **Run full test suite, verify 0 new regressions.** -35. **Code review subagent on full port.** -36. **Visual verify with user — full motion test matrix.** - ---- - -## Implementation order (strict serial) - -Bottom-up, each phase must build green before next: - -1. **Phase A** (per-tick correction) — undoes the L.3 regression. Smallest, surgical. Highest priority. -2. **Phase D** (hard-teleport branch) — additive plumbing, no regression risk. -3. **Phase B** (substepping) — replaces ObservedOmega. Medium risk. -4. **Phase E** (VectorUpdate corrections) — drops acdream workarounds. Medium risk. -5. **Phase G** (NPC convergence) — depends on Phase A solid. -6. **Phase F** (Sticky + Constraint) — additive ports. -7. **Phase C** (UM handling rewrite + cycle picker) — biggest single change. Replaces 600 lines. -8. **Phase H** (local player cleanup) — independent of others. -9. **Phase I** (cleanup) — last, after everything works. -10. **Phase J** (tests + review + verify) — gating ship. - ---- - -## Acceptance - -- All 3 reported user issues fixed: - 1. Stop after running settles within ≤1 UP cycle (~200ms) — apply_current_movement reads Ready → velocity 0 → body stops. - 2. Backward walk plays backward animation (no flip to forward run) — verified by ApplyServerControlledVelocityCycle removal in 91bf1e0. - 3. Long co-run drift bounded by DesiredDistance (0.05m) — apply_current_movement per-tick keeps velocity rotated by current orientation. -- Build green. -- All existing tests pass (8 pre-existing failures unchanged; new tests added for per-axis dispatch). -- Code review passes. -- Visual verify by user. diff --git a/docs/research/2026-05-04-l3-port/00-port-plan.md b/docs/research/2026-05-04-l3-port/00-port-plan.md deleted file mode 100644 index 6e13ecb0..00000000 --- a/docs/research/2026-05-04-l3-port/00-port-plan.md +++ /dev/null @@ -1,277 +0,0 @@ -# L.3 — Player remote motion: retail-faithful port plan - -**Date:** 2026-05-04 -**Source research:** [01-per-tick.md](01-per-tick.md), [02-um-handling.md](02-um-handling.md), [03-up-routing.md](03-up-routing.md), [04-interp-manager.md](04-interp-manager.md), [05-position-manager-and-partarray.md](05-position-manager-and-partarray.md), [06-acdream-audit.md](06-acdream-audit.md) -**Goal:** Reported user issues — body keeps walking after actor stops, backward walk regression flips to forward-run animation, position drift over time during co-running. Fix all three by porting retail's motion pipeline faithfully. - ---- - -## What retail actually does (synthesized) - -**Per-tick body advancement** (`CPhysicsObj::UpdateObjectInternal` @ acclient 0x005156b0 → `UpdatePositionInternal` @ 0x00512c30): - -``` -1. Frame local = identity // var_40 cached -2. CPartArray::Update writes animation root motion into local - - For locomotion cycles (Walk/Run/Sidestep), translation += seqVel × dt - - For Ready/idle cycles, no translation -3. PositionManager::adjust_offset(local, dt) modifies local in-place: - - InterpolationManager::adjust_offset: - * If queue empty OR body within DesiredDistance(0.05m) of head → no-op - (animation root motion drives) - * Else → OVERWRITE local.translation with direction × min(catchUpSpeed × dt, distance) - (catch-up REPLACES root motion this frame) - - StickyManager / ConstraintManager (deferred — niche features) -4. Frame::combine(out, m_position.frame, local) — compose with current world frame -5. UpdatePhysicsInternal Euler-integrates m_velocityVector × dt INTO out.origin - (gated on velocity² > 0; for walking remotes m_velocityVector=0 so no-op) -6. transition() — sphere sweep from m_position to out -7. set_frame(resolved) or SetPositionInternal(transition result) -``` - -**Critical retail invariants:** -- `m_velocityVector` is 0 for walking remotes. Only set by outbound jump (LeaveGround) + inbound 0xF74E VectorUpdate. -- ALL visible motion comes from animation root motion + InterpolationManager catch-up. -- Catch-up speed = `2 × motion_max_speed × dt` where `motion_max_speed` = current cycle's actual velocity magnitude. -- Same pipeline for every entity — no player-vs-NPC special-casing at per-tick layer. - -**UM (UpdateMotion) handling** (`CMotionInterp::move_to_interpreted_state` @ 0x00528a90): -- Inbound 0xF74C → bulk `copy_movement_from` of all 7 InterpretedState fields (acdream already does this). -- **Stop signal is implicit**: flag 0x02 (forward) cleared → `ForwardCommand` defaults to `Ready`, `ForwardSpeed = 1.0`. No explicit "stop motion" packet. -- Backward-walk arrives pre-adjusted: sender's `adjust_motion` flips `WalkBackward → WalkForward + speed=-0.65×s`; receiver bulk-copies. -- Side-axis and turn-axis fire `DoInterpretedMotion` per axis (acdream already does this). - -**UP (UpdatePosition) routing** (`CPhysicsObj::MoveOrTeleport` @ 0x00516330): -- Tri-state decision tree: - - **Hard teleport**: teleport-seq advanced OR cell == 0 → `SetPosition` with flags 0x1012 (Slide+Placement+SendPositionEvent). Body.Position changes immediately. - - **InterpolateTo (queue)**: grounded AND distance < 96m → `position_queue` mutated; **Body.Position does NOT change**. - - **Slide-snap**: grounded AND distance ≥ 96m → `StopInterpolating` + `SetPositionSimple`. - - **Airborne**: no-op (gravity arc continues from launch velocity). -- Orientation rides the Position struct's Frame — never queued separately. Hard-snapped on UP. - -**Constants verified from named binary:** -- `MAX_PHYSICS_DISTANCE = 96` m -- `CREATURE_OUTSIDE_BLIP_DISTANCE = 100` m -- `CREATURE_INSIDE_BLIP_DISTANCE = 20` m -- `MAX_INTERPOLATED_VELOCITY_MOD = 2.0` -- `MAX_INTERPOLATED_VELOCITY = 7.5` m/s (fallback when motion_max unavailable) -- `MIN_DISTANCE_TO_REACH_POSITION = 0.20` m -- `DESIRED_DISTANCE = 0.05` m -- `CREATURE_FAILED_INTERPOLATION_PERCENTAGE = 0.30` -- `StallCheckFrameInterval = 5` frames -- `StallFailCountThreshold = 3` fails -- Queue cap = 20 - ---- - -## Where acdream diverges (top issues from audit) - -1. **Per-tick `apply_current_movement` on player remotes** (GameWindow.cs:6599) writes `body.Velocity = RunAnimSpeed × ForwardSpeed × orientation`. Retail spec: `body.Velocity` must be 0 for walking remotes. **This is the central regression.** - -2. **Two parallel per-tick paths.** Env-var path (L6118-6445) is the L.3 architecture but regressed (issue #40). Legacy path (L6446-6764) is production default and fundamentally wrong vs L.3. Need to collapse into one correct path. - -3. **`IsPlayerGuid` gates at 5 sites** route player remotes through the broken `apply_current_movement` else-branch. Retail uses one pipeline for all entities. - -4. **InterpolationManager bugs** (per research 04): - - `progress_quantum` accumulates `step` (distance) instead of `dt` (time) - - Secondary stall check missing `/dt` factor - - Missing `NodeCompleted(0)` head-pop on stall (one bad waypoint stalls indefinitely) - - Missing `transient_state & 1` gate - - Missing far-distance force-blip via `_failCount = 4` on enqueue - -5. **`CPartArray::Update` collapsed into single `seqVel × dt` per tick.** For locomotion cycles, both retail and acdream synthesize velocity from formula (Humanoid dat ships zero), so this is OK for the user's reported issues. The per-keyframe loop matters for non-locomotion (emotes etc) — defer. - -6. **Cycle picker in OnLiveMotionUpdated is acdream-original** (forward → sidestep → turn → Ready priority). Retail just plays the cycle the wire told it to play. Defer; not the immediate cause of reported bugs. - ---- - -## Port plan — concrete changes - -Targeted at fixing all three reported user symptoms. Defers cosmetic divergences (cycle picker, full per-keyframe loop, sticky/constraint managers) to follow-up phases. - -### Step 1: Fix `InterpolationManager.cs` bugs - -File: `src/AcDream.Core/Physics/InterpolationManager.cs` - -Changes (all from research doc 04, sections 3 + 7): - -1. **`AdjustOffset`**: change `_progressQuantum += step;` → `_progressQuantum += (float)dt;`. Accumulate time, not distance. -2. **Secondary stall check**: change `cumulative / progressQuantum < 0.30` → `(cumulative / progressQuantum) / dt < 0.30`. Match retail formula. -3. **Stall handling**: when stall threshold exceeded, pop head node into a `_blipToPosition` field. Don't return snap delta inside AdjustOffset. -4. **Add `UseTime()` method**: separately performs the blip via `body.SetPositionSimple` when `_failCount > StallFailCountThreshold`. Called once per tick from per-tick path. -5. **`Enqueue`**: when distance from current body to enqueued position exceeds `MAX_PHYSICS_DISTANCE` (96m), pre-arm `_failCount = StallFailCountThreshold + 1` so next tick's `UseTime` blips immediately. -6. **Add `IsLive` parameter to AdjustOffset** corresponding to `transient_state & 1`. Default true; pass through. - -Tests to add: -- `progress_quantum` accumulates dt, not step -- Stall after 3 windows pops head and arms blip -- `UseTime` calls `SetPositionSimple` when armed and clears state -- Far enqueue arms immediate blip - -### Step 2: Unify the per-tick path in `GameWindow.TickAnimations` - -File: `src/AcDream.App/Rendering/GameWindow.cs` - -Delete the env-var fork. Single per-tick path for all remote entities (player or NPC). This is the bulk of the work — replace lines 6118-6764 (~640 LOC) with a single ~150 LOC retail-faithful port. - -Per-tick algorithm (matching retail `UpdatePositionInternal`): - -```csharp -// Step 0: Force grounded transient flags for non-airborne (no change) -if (!rm.Airborne) { - rm.Body.TransientState |= Contact | OnWalkable | Active; - rm.Body.Velocity = Vector3.Zero; // RETAIL INVARIANT: walking remotes have zero velocity -} - -// Step 1: NPC MoveTo branch (existing — unchanged) -if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) { - /* RemoteMoveToDriver.Drive (existing — keep as-is) */ -} - -// Step 2: PositionManager.ComputeOffset returns either: -// - queue catch-up correction (when body drifted from head) -// - animation root motion (when at head OR queue empty) -// - zero (when queue empty AND seqVel zero — Ready cycle, idle observer) -var seqVel = ae.Sequencer?.CurrentVelocity ?? Vector3.Zero; -float maxSpeed = seqVel.Length(); // motion_max_speed = cycle's actual velocity -if (maxSpeed <= 0f) maxSpeed = MotionInterpreter.RunAnimSpeed; // 4.0 fallback -var preIntegratePos = rm.Body.Position; -var offset = rm.Position.ComputeOffset(dt, preIntegratePos, seqVel, rm.Body.Orientation, rm.Interp, maxSpeed); -var postIntegratePos = preIntegratePos + offset; - -// Step 3: Manual omega integration (preserve existing — bypasses MinQuantum gate) -ApplyObservedOmega(rm, dt); - -// Step 4: Physics integration. With body.Velocity=0 (set in step 0), this is a -// no-op for grounded remotes. For airborne remotes, gravity drives body.Position. -rm.Body.calc_acceleration(); -rm.Body.UpdatePhysicsInternal(dt); -postIntegratePos = rm.Body.Position; // re-read in case airborne integration moved it - -// Step 5: Collision sweep (preserve existing — Commit B added this for slope tracking) -if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) { - var resolveResult = _physicsEngine.ResolveWithTransition( - preIntegratePos, postIntegratePos, rm.CellId, - sphereRadius: 0.48f, sphereHeight: 1.2f, - stepUpHeight: 0.4f, stepDownHeight: 0.4f, - isOnGround: !rm.Airborne, body: rm.Body, - moverFlags: ObjectInfoState.EdgeSlide); - rm.Body.Position = resolveResult.Position; - if (resolveResult.CellId != 0) rm.CellId = resolveResult.CellId; - /* existing post-resolve landing detection — unchanged */ -} - -// Step 6: UseTime — fire stall blips for non-airborne entities with armed fail counter -if (!rm.Airborne) { - rm.Interp.UseTime(rm.Body); -} - -// Step 7: Sync renderable -ae.Entity.Position = rm.Body.Position; -ae.Entity.Rotation = rm.Body.Orientation; -``` - -**Removed:** the entire `apply_current_movement` else-branch (current L6599) for player remotes. The NPC `HasServerVelocity` synth-velocity branch (current L6493-6511) — NPCs don't need this either, they should also use queue-based motion. Defer NPC migration to a follow-up if it risks regression; for this phase, keep the NPC HasServerVelocity branch but remove the player path. - -**Conservative scope:** Player remotes get the L.3 path. NPCs keep their existing `HasServerVelocity` branch + `RemoteMoveToDriver`. Both can converge later. - -### Step 3: Update `OnLivePositionUpdated` UP routing - -File: `src/AcDream.App/Rendering/GameWindow.cs:3425-3824` - -Replace the legacy default branch (L3628-3761) with the env-var branch's logic — but keep the synth-velocity computation for NPCs (which still uses it via `HasServerVelocity`). - -For player remotes within 96m grounded: `Interp.Enqueue(worldPos, heading, isMovingTo:false)`. **No hard-snap** of `body.Position`. - -For player remotes outside 96m or first UP: hard-snap + `Interp.Clear()`. - -For airborne player remotes: existing landing-transition logic. - -For NPCs: existing path (synth velocity, hard-snap, etc.) — preserve. - -### Step 4: Drop `ApplyServerControlledVelocityCycle` - -File: `src/AcDream.App/Rendering/GameWindow.cs:3325-3423` - -This whole function exists because of issue #39 — Shift-toggle Run↔Walk doesn't fire a fresh UM. Per research doc 02, **retail's wire actually does fire fresh UMs on Shift-toggle** (because retail's outbound `apply_run_to_command` re-runs and produces a different `ForwardSpeed`). If our observed acdream-on-retail behavior shows UMs missing on Shift-toggle, that's an ACE bug — not something we should compensate for client-side. - -Drop the function. Drop the call site at line 3791. Drop `RemoteMotion.LastUmUpdateTime`. Drop the `IsPlayerGuid` gates the function relies on. - -If issue #39 reappears after this, file an ACE bug rather than re-adding client-side hysteresis logic. - -### Step 5: Drop `IsPlayerGuid` per-tick gates - -Five sites identified in audit: -- L706 (definition — keep, used elsewhere) -- L3349 (`ApplyServerControlledVelocityCycle` — dropped in Step 4) -- L3727 (UP velocity-adoption fallback — review, may stay for NPCs) -- L6493 (NPC HasServerVelocity branch — keep for now, NPCs) -- L6512 (NPC ServerMoveToActive branch — keep, NPCs) -- L6588 (NPC ServerMoveToActive without dest — keep, NPCs) - -Effectively: drop the L3349 gate via Step 4. The remaining gates correctly route NPC paths. - -### Step 6: Verify CPartArray velocity for locomotion cycles - -File: `src/AcDream.Core/Physics/AnimationSequencer.cs` - -`CurrentVelocity` synthesis at lines 614-646 already matches retail constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25). Per research doc 05, this is the right approximation for Humanoid (dat ships zero velocity). **No changes needed.** - -For sign-flipped backward walk (`WalkForward + speed=-1`), `adjustedSpeed = speedMod` directly preserves the negative sign. `CurrentVelocity.Y = WalkAnimSpeed × -1 = -3.12`. Body root-motions backward in body-local frame. Rotated by orientation = backward in world frame. Correct. - -### Step 7: Test, code review, visual verify - -- Build: `dotnet build` -- Tests: existing `ServerControlledLocomotionTests` (7) should still pass; new `InterpolationManagerStallTests` for the bug fixes -- Code review subagent on the unified per-tick path -- Visual verify with user — full motion test matrix: - 1. Steady run forward - 2. Steady walk forward - 3. Steady walk backward - 4. Steady strafe right - 5. Steady strafe left - 6. Run + turn - 7. Walk + turn - 8. Run → Stop (release W) - 9. Walk → Stop - 10. Run → Shift toggle to walk - 11. Walk → Shift release to run - 12. Jump + land - ---- - -## What this fix does NOT address (deferred) - -- **Full per-keyframe `CPartArray::Update` loop** — for non-locomotion cycles (emotes, idle subtleties). Defer until visible bug. -- **StickyManager / ConstraintManager** — niche retail features (locked targets, etc). -- **Branch A (Hard teleport)** in MoveOrTeleport — needs `teleport_timestamp` plumbing through the protocol. -- **NPC migration to L.3 path** — keeps existing `HasServerVelocity` synth path; will converge later. -- **OnLiveMotionUpdated cycle picker** — current acdream-original logic. Retail just plays the wire's cycle directly. Defer if user-visible bugs don't depend on it. - ---- - -## Acceptance - -- All three reported user issues resolved: - 1. Stop after running: body settles within ≤1 UP cycle (200ms) of UM(Ready) arrival. - 2. Backward walk: body moves backward, animation plays backward (no flip to forward-run). - 3. Long co-run: positional sync holds — drift bounded by `DesiredDistance` (0.05m). -- `dotnet build` green. -- `dotnet test` green (existing tests pass + new tests for InterpolationManager bug fixes). -- Code review pass on the unified per-tick path. -- Visual verify by user. - ---- - -## Implementation order - -Strict serial — each step must build green before next: - -1. **InterpolationManager bug fixes** (Step 1) — small, isolated, testable in unit tests. -2. **Drop `ApplyServerControlledVelocityCycle`** (Step 4) — surgical removal. -3. **Unify per-tick path** (Step 2) — large change. Will need a code review after. -4. **Update UP routing** (Step 3) — surgical replacement of OnLivePositionUpdated default branch. -5. **Build + run tests** (Step 7). -6. **Visual verify with user** (Step 7). - -Do NOT proceed past step 5 to user testing if any earlier step is incomplete or broken. diff --git a/docs/research/2026-05-04-l3-port/01-per-tick.md b/docs/research/2026-05-04-l3-port/01-per-tick.md deleted file mode 100644 index 8789134c..00000000 --- a/docs/research/2026-05-04-l3-port/01-per-tick.md +++ /dev/null @@ -1,613 +0,0 @@ -# L.3 Per-Tick Body Update Pipeline — Retail Reference - -**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named, Binary Ninja pseudo-C). -**Scope**: How retail advances a `CPhysicsObj` (player or remote) **one physics tick**, from the entry call through animation-driven motion, physics integration, and collision resolution. -**Purpose**: Correct the env-var per-tick path in `GameWindow.cs` (~line 6428+) so it matches retail order-of-operations and behavior, especially for "walking remote" cases where `m_velocityVector` is supposed to stay zero. - ---- - -## Top-level call graph - -``` -CPhysicsObj::UpdateObjectInternal(this, dt) @ 0x005156b0 - └─ CPhysicsObj::UpdatePositionInternal(this, dt, &outFrame) @ 0x00512c30 - │ ├─ CPartArray::Update(part_array, dt, &localFrame) @ 0x00517db0 - │ │ └─ CSequence::update(&sequence, dt, &localFrame) @ 0x00525b80 - │ │ ├─ CSequence::update_internal(...) // advances anim cursor - │ │ └─ CSequence::apply_physics(this, frame, dt, dt) @ 0x00524ab0 - │ │ ├─ frame.m_fOrigin += dt * sequence.velocity // ROOT MOTION - │ │ └─ Frame::rotate(frame, dt * sequence.omega) // ROOT ROT - │ ├─ PositionManager::adjust_offset(pos_mgr, &localFrame, dt) @ 0x00555190 - │ │ ├─ InterpolationManager::adjust_offset - │ │ ├─ StickyManager::adjust_offset - │ │ └─ ConstraintManager::adjust_offset - │ ├─ Frame::combine(outFrame, &m_position.frame, &localFrame) @ 0x005122e0 - │ │ // outFrame = m_position.frame ⊗ localFrame (rotate-translate compose) - │ ├─ CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame) @ 0x00510700 - │ │ // Euler integration of m_velocityVector + m_accelerationVector + m_omegaVector - │ │ // Modifies outFrame.m_fOrigin (translation) and rotation (Frame::grotate) - │ │ // Modifies m_velocityVector (acceleration step) - │ └─ CPhysicsObj::process_hooks(this) - ├─ CPhysicsObj::transition(this, &m_position, &outFrame, 0) @ 0x00512dc0 - │ └─ CTransition::find_valid_position(...) // BSP / sphere sweep - ├─ CPhysicsObj::set_frame(this, &outFrame) @ 0x00514090 // (failure path) - │ OR - ├─ CPhysicsObj::SetPositionInternal(this, transition) @ 0x00515330 // (success path) - └─ DetectionManager / TargetManager / MovementManager / CPartArray::HandleMovement / PositionManager::UseTime -``` - ---- - -## 1. `CPhysicsObj::UpdateObjectInternal(this, dt)` — entry - -**Address**: `0x005156b0` (line 283611) -**Signature**: `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt)` - -### What it does (plain English) - -This is the **per-tick entry** for a physics object. It branches on `transient_state`: - -- If `transient_state` has the *high bit* clear (i.e. `>= 0`, meaning the object is *passive* / dormant), it skips physics entirely and only ticks `ParticleManager` + `ScriptManager`. -- Otherwise it runs the **active path**: build a candidate next-frame, sweep collision, commit position, and tick all per-frame managers (Detection, Target, Movement, Position, parts). - -### Order of operations (active path) - -| # | Line | What happens | -|---|------|--------------| -| 1 | 283631 | If `transient_state[1] & 1` → `set_ethereal(this, 0, 0)` | -| 2 | 283634 | `this->jumped_this_frame = 0` | -| 3 | 283635-283643 | Local stack `Frame var_40` initialized to identity (qw=1, origin=0). Note `var_48` holds `0x796910` = `Position::vtable`. | -| 4 | 283644 | `Frame::cache(&var_40)` — recompute `m_fl2gv[0..8]` rotation matrix from quaternion | -| 5 | 283646 | **`UpdatePositionInternal(this, dt, &var_40)`** — fills `var_40` with the candidate next-frame | -| 6 | 283651-283655 | Check `part_array && CPartArray::GetNumSphere(part_array)` — does this object have a collision sphere? | -| 7a | 283655 (no spheres or zero-translation): set `m_position.frame = var_40` directly via `set_frame`, zero `cached_velocity`, no transition. | -| 7b | 283657 (has spheres AND moved): run `transition()` collision sweep | -| 7b1 | 283661-283670 | If `state[1] & 1` (Hooks?), set heading from translation direction; else if `(state & ScaledVelocity) && !is_zero(m_velocityVector)`, set heading from velocity. | -| 7b2 | 283673 | `transition(this, &m_position, &var_48, 0)` — sweeps sphere from current pos to candidate pos `var_48`. Note `var_48` here refers to the **Position** struct that has been built (the local var holds an embedded Position with vtable `0x796910` and frame `var_40`). | -| 7b3 | 283675-283696 | If `transition == nullptr` (no valid path): keep current frame via `set_frame(&var_40)`, zero `cached_velocity`. Otherwise: compute `cached_velocity = (final_pos - current_pos) / dt`, then `SetPositionInternal(this, transition)`. | -| 8 | 283733 | `DetectionManager::CheckDetection` | -| 9 | 283738 | `TargetManager::HandleTargetting` | -| 10 | 283743 | `MovementManager::UseTime` | -| 11 | 283748 | `CPartArray::HandleMovement` | -| 12 | 283753 | `PositionManager::UseTime` | -| 13 | 283755 | `goto label_5159b8` → ticks `ParticleManager` + `ScriptManager`, then returns | - -### Side effects - -- `this->m_position.frame` ← committed final pose (via `set_frame` or `SetPositionInternal`) -- `this->cell` ← may change (cell crossing) -- `this->cached_velocity` ← actual delta achieved this tick / dt (used elsewhere for collision profiles, etc.) -- `this->m_velocityVector` ← updated by `UpdatePhysicsInternal` (acceleration integration) -- `this->jumped_this_frame` ← cleared -- `this->contact_plane`, `this->sliding_normal`, `this->transient_state` bits 1/4/8 ← updated by `SetPositionInternal` - -### Key conditions - -- **Spheres-and-moved gate** at line 283655 + 283657: `transition()` is only called when the object has a sphere AND the candidate frame's origin moved (`!operator==(zero_vec, m_position.frame.m_fOrigin)`). The `x` here is a stack zero vector compared against current origin — if origin is at world-zero it skips. Wait, re-reading: actually `x` was overwritten on line 283641 to `0f`, then operator== between `&x` (stack zero) and `&this->m_position.frame.m_fOrigin`. This is checking "did `UpdatePositionInternal` move us off `m_position.frame.m_fOrigin`"? **No** — it's comparing the local stack vector `x` (zero) against `m_position`. This is effectively `is_zero(m_position)` which is almost always false. **Re-read more carefully:** at line 283641 `float x = 0f;` is just initializing 3 stack floats (Vector3 x/y/z). The compare is "is the player at world origin (0,0,0)?". That's a degenerate check. So the practical interpretation: spheres-with-collision is the gate, and the no-sphere path just commits `var_40` directly. - ---- - -## 2. `CPhysicsObj::UpdatePositionInternal(this, dt, outFrame)` — build candidate frame - -**Address**: `0x00512c30` (line 280817) -**Signature**: `void __thiscall CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)` - -### What it does (plain English) - -Builds the **candidate next-tick frame** in `outFrame`. Three contributions, in order: - -1. **Animation root motion** — `CPartArray::Update` calls `CSequence::apply_physics`, which writes `localFrame.origin = dt * sequence.velocity` and rotates `localFrame` by `dt * sequence.omega`. This is the per-cycle baked velocity from the current MotionData (e.g., RunForward emits ~4 m/s on +Y in body local). -2. **Position-manager offset** — `PositionManager::adjust_offset` lets `InterpolationManager`, `StickyManager`, `ConstraintManager` mutate the local frame. -3. **Compose with current world pose** — `Frame::combine(outFrame, &this->m_position.frame, &localFrame)` rotates the local-frame translation into world space and adds it to current world pos; multiplies quaternions. -4. **Physics integration on the composed frame** — `UpdatePhysicsInternal` Euler-integrates `m_velocityVector` and `m_omegaVector` *into the same outFrame* (this is the only place generalized velocity is consumed — see §4). - -### Order of operations - -| # | Line | What happens | -|---|------|--------------| -| 1 | 280820-280826 | Init local `var_40` = identity Frame, plus `var_c/var_8/var_4 = 0` (these are 3 floats that look like a `Vector3 localTranslation` slot). | -| 2 | 280827 | `Frame::cache(&var_40)` — populate rotation matrix `m_fl2gv[0..8]` (will be re-cached after `apply_physics`). | -| 3 | 280829 | If `(state[1] & 0x40) == 0` (i.e. `PartsArray::Frozen` flag NOT set): | -| 3a | 280834 | `CPartArray::Update(part_array, dt, &var_40)` — anim root motion goes into `var_40` | -| 3b | 280836-280848 | If `transient_state & 2` (Stuck/Active?), scale the captured `var_c/var_8/var_4` (looks like a velocity scratch vector) by `m_scale`; else zero them. **This branch's effect on `var_40` is unclear from the decomp — possibly dead/diagnostic code.** | -| 4 | 280853-280857 | If `position_manager != nullptr` → `PositionManager::adjust_offset(pm, &var_40, dt)` | -| 5 | 280860 | `Frame::combine(outFrame, &this->m_position.frame, &var_40)` — outFrame = world-pose ⊗ local-frame | -| 6 | 280862 | If NOT `(state[1] & 0x40)` → `UpdatePhysicsInternal(this, dt, outFrame)` (Euler step on the composed frame) | -| 7 | 280865 | `process_hooks(this)` — runs queued FP-hooks (scale fade, translucency fade, etc.) | - -### Verbatim retail snippets - -```c -// Line 280827-280834 -Frame::cache(&var_40); -if ((this->state[1] & 0x40) == 0) { - CPartArray* part_array = this->part_array; - if (part_array != 0) - CPartArray::Update(part_array, dt, &var_40); - ... -} -``` - -```c -// Line 280853-280860 — order-critical: adjust_offset BEFORE combine -if (position_manager != 0) - PositionManager::adjust_offset(position_manager, &var_40, dt); -Frame::combine(outFrame, &this->m_position.frame, &var_40); -``` - -```c -// Line 280862-280865 — physics integration AFTER compose, hooks last -if ((this->state[1] & 0x40) == 0) - CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame); -CPhysicsObj::process_hooks(this); -``` - -### Side effects - -- `outFrame` ← fully populated candidate next-tick world frame -- `m_velocityVector`, `m_omegaVector` ← updated inside UpdatePhysicsInternal -- `m_position.frame` ← **NOT TOUCHED** here (commit happens in `set_frame`/`SetPositionInternal`) - -### Critical observation: state.0x40 ("Frozen") double-gate - -The `Frozen` flag both: -- Skips animation root motion (CPartArray::Update) -- Skips physics integration (UpdatePhysicsInternal) - -But still runs `Frame::combine` against the (now-empty) localFrame. Net effect: a frozen object's outFrame == m_position.frame (no motion). - ---- - -## 3. `CSequence::apply_physics(seq, frame, dt, dt)` — animation root motion source - -**Address**: `0x00524ab0` (line 300955) -**Signature**: `void __thiscall CSequence::apply_physics(const CSequence* this, Frame* arg2, double arg3, double arg4)` - -### What it does - -Writes the **animation's baked locomotion** into `frame`. This is the *only* code path that produces translation for "walking" remotes — `m_velocityVector` stays at zero for them and `UpdatePhysicsInternal`'s Euler-translation step is a no-op. - -### Pseudocode (plain) - -```c -double scale = fabs(arg3); // dt magnitude -if (arg4 < 0) scale = -scale; // sign from arg4 -frame->origin.x += scale * sequence->velocity.x; -frame->origin.y += scale * sequence->velocity.y; -frame->origin.z += scale * sequence->velocity.z; -Vector3 omegaScaled = scale * sequence->omega; -Frame::rotate(frame, &omegaScaled); // local-frame quaternion rotate -``` - -`sequence->velocity` and `sequence->omega` are **updated as the AnimSequenceNodes advance** (CSequence::update_internal handles cursor advancement; apply_physics consumes the current cycle's baked velocity). This is the data driven via MotionData fields — e.g. RunForward MotionData has `Velocity = (0, 4.0, 0)` baked in, scaled by speed multiplier. - -### Why this matters for L.3 - -> The spec says `m_velocityVector` stays at 0 for walking remotes — verify - -**Confirmed.** For locomotion-driven remotes: -1. Server sends `UpdateMotion(RunForward, speed=N)`. -2. Sequencer enters the RunForward cycle. -3. `CSequence::apply_physics` writes `dt * (0, 4*N, 0)` to the local frame's origin every tick. -4. `Frame::combine` rotates that body-local +Y into world space using `m_position.frame.m_fl2gv` and adds to world pos. -5. `UpdatePhysicsInternal` runs but with `m_velocityVector ≈ 0` (no physics push) → its Euler-translation step adds nothing meaningful. It still does the omega-rotate step using `m_omegaVector` if non-zero. - -This is why **acdream's `apply_current_movement` → `body.Velocity` → `UpdatePhysicsInternal` chain is the wrong shape for remotes.** Retail does NOT push locomotion through `m_velocityVector`. It pushes locomotion through the sequencer's baked-velocity → local frame → combine. - ---- - -## 4. `CPhysicsObj::UpdatePhysicsInternal(this, dt, frame)` — physics integration - -**Address**: `0x00510700` (line 278460) -**Signature**: `void __thiscall CPhysicsObj::UpdatePhysicsInternal(CPhysicsObj* this, float dt, Frame* frame)` - -### What it does (plain English) - -Runs **Euler integration of `m_velocityVector` + `m_accelerationVector` + `m_omegaVector`** on the supplied `frame`. This is what advances physics-driven motion (gravity falls, jump arcs, knockbacks). Walking locomotion does NOT flow through here. - -### Order of operations - -| # | Line | What happens | -|---|------|--------------| -| 1 | 278463-278467 | Compute `var_28 = velocity.x² + velocity.y² + velocity.z²` | -| 2 | 278473 | If `var_28 > 0` (i.e. velocity is non-zero, the FP comparison machinery checks this): | -| 2a | 278475-278487 | If `var_28 > 50²` (terminal speed cap), normalize velocity and scale to magnitude 50. | -| 2b | 278490 | `calc_friction(this, dt, var_28)` — friction adjusts velocity | -| 2c | 278491-278502 | If `var_28 < 0.0625 + 0.0002` (≈ `0.25²` threshold), zero velocity (deadband). | -| 2d | 278505-278511 | **Euler translation**: `frame.origin += dt * velocity + 0.5 * dt² * acceleration` (per-axis) | -| 3 | 278513-278518 | `else if (movement_manager == 0 && transient_state & 2)` — clear `0x80` from transient_state | -| 4 | 278521-278523 | **Velocity step**: `velocity += dt * acceleration` | -| 5 | 278524-278528 | **Omega rotation**: build `var_18..` = `dt * omegaVector`, then `Frame::grotate(frame, &dtOmega)` (global-frame rotate by axis-angle, sin/cos applied to half-angle, quat multiply) | - -### Verbatim retail snippets - -```c -// Line 278505-278511 — translation update (per axis) -float var_20 = (acceleration.y * 0.5f) * dt * dt; -... // similar for x, z -frame->origin.x += (dt * velocity.x) + (acceleration.x * 0.5f * dt * dt); -frame->origin.y += (dt * velocity.y) + var_20; -frame->origin.z += (dt * velocity.z) + (acceleration.z * 0.5f * dt * dt); - -// Line 278521-278523 — velocity step (always runs, even when velocity was 0) -velocity.x += dt * acceleration.x; -velocity.y += dt * acceleration.y; -velocity.z += dt * acceleration.z; - -// Line 278524-278528 — angular rotation (always runs) -Vector3 dtOmega = dt * omegaVector; -Frame::grotate(frame, &dtOmega); -``` - -### Critical observations - -1. **Translation step is gated** — only runs when `velocity² > 0`. For a walking remote with `m_velocityVector == 0`, this entire block is skipped. The animation's baked velocity (already in `frame.origin` from `apply_physics` + `combine`) is preserved. -2. **Velocity step always runs** — even when initial velocity was zero, gravity (`acceleration.z = PhysicsGlobals::gravity` when `state[1] & 4`, i.e. `Gravity`) accumulates `velocity.z -= 9.8 * dt` per tick. -3. **Omega step always runs** — uses `m_omegaVector` (NOT sequencer omega). This is for knockback spin / scripted rotation; sequencer omega is consumed inside `apply_physics`. -4. **Zero-velocity deadband** at `|v| < 0.25` applies AFTER friction. Friction-decayed velocity below 0.25 m/s snaps to 0. - ---- - -## 5. `CPhysicsObj::transition(this, fromPos, toPos, flags)` — collision sweep - -**Address**: `0x00512dc0` (line 280904) -**Signature**: `const CTransition* __thiscall CPhysicsObj::transition(CPhysicsObj* this, const Position* fromPos, const Position* toPos, int32_t flags)` - -### What it does - -Allocates a `CTransition` (collision-sweep workspace), initializes it with the object's spheres + path (fromPos → toPos in cell), tunes "frames stationary fall" from `transient_state`, calls `find_valid_position`, cleans up the transition workspace, returns the resolved `CTransition*` if successful or `nullptr` if no valid position. - -### Order of operations - -| # | Line | What happens | -|---|------|--------------| -| 1 | 280907 | `CTransition* result = CTransition::makeTransition()` | -| 2 | 280911 | `init_object(result, this, get_object_info(this, result, flags))` — copies state flags into `CTransition::object_info` | -| 3 | 280915-280936 | If has spheres → `init_sphere(result, count, spheres, scale)`; else → `init_sphere(result, 1, &dummy_sphere, 1.0)` | -| 4 | 280939 | `init_path(result, this->cell, fromPos, toPos)` — sets up SpherePath | -| 5 | 280940-280947 | Set `frames_stationary_fall` from transient_state high bits: `0x40→3`, `0x20→2`, `0x10→1` | -| 6 | 280949 | `int valid = CTransition::find_valid_position(result)` — runs full sweep | -| 7 | 280950 | `cleanupTransition(result)` — releases sphere copies / scratch state | -| 8 | 280952-280953 | Return `result` if `valid != 0`, else `0` | - -### Side effects on caller - -The returned `CTransition*` carries: -- `result->sphere_path.curr_pos` — final resolved Position (cell + frame) -- `result->sphere_path.curr_cell` — final cell (may differ from start, may be null = lost) -- `result->collision_info.contact_plane` — current ground plane -- `result->collision_info.contact_plane_valid`, `contact_plane_is_water`, `contact_plane_cell_id` -- `result->collision_info.sliding_normal`, `sliding_normal_valid` -- `result->cell_array` — list of cells visited during the sweep - -These all flow into `SetPositionInternal`. - ---- - -## 6. `CPhysicsObj::SetPositionInternal(this, transition)` — commit - -**Address**: `0x00515330` (line 283399) -**Signature**: `int32_t __thiscall CPhysicsObj::SetPositionInternal(CPhysicsObj* this, const CTransition* arg2)` - -### What it does - -Commits the transition's resolved position back into the physics object: copies the final frame, updates cell membership, updates contact plane / sliding normal / transient_state walkable bits, recalculates acceleration based on the new ground state, and fires `handle_all_collisions` for any contact reports. - -### Order of operations - -| # | Line | What happens | -|---|------|--------------| -| 1 | 283402-283403 | Capture `transient_state`, `curr_cell` | -| 2 | 283405-283410 | If `curr_cell == nullptr` (lost): `prepare_to_leave_visibility` + `store_position(curr_pos)` + `GotoLostCell` + clear `transient_state & 0x80` | -| 3 | 283414-283456 | If same cell: update `m_position.objcell_id` and child cell ids; else `change_cell(curr_cell)` | -| 4 | 283458 | `set_frame(this, &curr_pos.frame)` — commit final frame to `m_position.frame` and propagate to part array | -| 5 | 283459-283464 | Copy `contact_plane` (N + d) and `contact_plane_cell_id` | -| 6 | 283465-283473 | If `contact_plane_valid` → `transient_state \|= 1 (Contact)`; else `&= ~1` | -| 7 | 283474 | `calc_acceleration(this)` — reset to gravity or zero based on Contact + Gravity flags | -| 8 | 283475-283483 | If `contact_plane_is_water` → `transient_state \|= 8`; else `&= ~8` | -| 9 | 283485-283510 | If now Contact: branch on `contact_plane.N.z >= PhysicsGlobals::floor_z` → `set_on_walkable(true)` else `(false)`. Else (not on contact): clear `transient_state & 2` (Active) — call `MovementManager::LeaveGround` if was active — then `calc_acceleration` again. | -| 10 | 283512-283523 | Copy `sliding_normal` + flag bit 4 (`SlidingNormal`) | -| 11 | 283524 | `handle_all_collisions(this, &collision_info, oldContact, oldActive)` — fires Weenie collision callbacks | -| 12 | 283526-283538 | If has cell + state has `0x10000` (HasPhysicsBSP) → `calc_cross_cells`; else → `remove_shadows_from_cells` + `add_shadows_to_cells(&cell_array)` | - -### `floor_z` constant - -`PhysicsGlobals::floor_z` is the cosine of the steepest walkable angle. Standard retail value is around `0.66417414` (≈ cos 49°). The check at line 283501-283506 is "is this slope steep enough that we are NOT on walkable ground?" — if `contact_plane.N.z < floor_z`, the slope is too steep, set `OnWalkable = false`. - ---- - -## 7. `Frame::combine(out, a, b)` — frame composition - -**Address**: `0x005122e0` (line 280355) -**Signature**: `void Frame::combine(Frame* out, const Frame* a, const Frame* b)` - -### What it does (plain English) - -Composes two frames: **out = a then b** in the sense of "apply a's transform, then add b in a's local space." - -```c -out.origin = a.origin + a.m_fl2gv * b.origin // rotate b's translation by a's matrix, add to a's origin -out.quaternion = a.quaternion * b.quaternion // standard quat multiply -``` - -The matrix `m_fl2gv` is the local-to-global rotation matrix, populated by `Frame::cache` from the quaternion. `m_fl2gv[0..8]` is column-major (with `[0]/[3]/[6]` = row 0, etc., based on the index pattern at line 280358). - -### Side effects - -- `out.m_fOrigin`, `out.qw/qx/qy/qz` ← computed -- `out.m_fl2gv` ← repopulated (via `set_rotate` which calls `cache`) - -### Important: `combine` reads `a.m_fl2gv` directly - -If `a.m_fl2gv` is stale (quaternion changed without `Frame::cache`), `combine` produces garbage translation. This is why `Frame::cache(&var_40)` is called explicitly at line 280827 in `UpdatePositionInternal` before any operation that reads the matrix. - ---- - -## 8. `Frame::cache(this)` — quat → rotation matrix - -**Address**: `0x00534df0` (line 319353) -**Signature**: `void __fastcall Frame::cache(Frame* this)` - -### What it does - -Populates `m_fl2gv[0..8]` (a 3×3 rotation matrix) from `qw/qx/qy/qz`. Standard quat-to-matrix formula: - -``` -m_fl2gv[0] = 1 - 2*(qy² + qz²) m_fl2gv[3] = 2*(qx*qy - qw*qz) m_fl2gv[6] = 2*(qx*qz + qw*qy) -m_fl2gv[1] = 2*(qx*qy + qw*qz) m_fl2gv[4] = 1 - 2*(qx² + qz²) m_fl2gv[7] = 2*(qy*qz - qw*qx) -m_fl2gv[2] = 2*(qx*qz - qw*qy) m_fl2gv[5] = 2*(qy*qz + qw*qx) m_fl2gv[8] = 1 - 2*(qx² + qy²) -``` - -### Why it matters - -Every time the quaternion changes (e.g., omega-rotate or `set_rotate`), the cached matrix MUST be refreshed before another `combine`/`localtoglobal` reads it. Retail's `Frame::set_rotate` calls `Frame::cache` internally; manual quaternion edits do not. - ---- - -## 9. `CPhysicsObj::set_frame(this, frame)` — commit frame to object - -**Address**: `0x00514090` (line 282139) - -### What it does - -Validates the frame, copies it to `this->m_position.frame`, propagates to `part_array` (unless `state[1] & 0x10` = `Particle` flag), and updates children. - -### Order - -1. `Frame::operator=(local_var_40, arg2)` — copy -2. If `IsValid(local) == 0 && IsValidExceptForHeading(local) != 0` → reset rotation to identity (origin preserved). This protects against NaN quats from numerical drift. -3. `Frame::operator=(this->m_position.frame, local)` — final assign -4. If NOT `(state[1] & 0x10)` → `CPartArray::SetFrame(part_array, &this->m_position.frame)` -5. `UpdateChildrenInternal(this)` — recurse to attached children - -### Why a SEPARATE local copy? - -So the validity-fix can run before committing. If we wrote directly into `m_position.frame` and then noticed it's invalid, we'd have already corrupted the live state. - ---- - -## 10. `PositionManager::adjust_offset(pm, frame, dt)` — three-manager pass - -**Address**: `0x00555190` (line 352090) - -### What it does - -Calls `adjust_offset(frame, dt)` on each of three optional managers in order: -1. `InterpolationManager` — smooths network-bursty position deltas -2. `StickyManager` — locks position to a target object (e.g., aetheria attaches) -3. `ConstraintManager` — clamps within a region - -Each is null-checked. For most remotes, all three are null, so this is a no-op. - ---- - -## 11. `CPartArray::Update(arr, dt, frame)` & `CSequence::update` - -**Addresses**: `0x00517db0` (CPartArray::Update, line 285883), `0x00525b80` (CSequence::update, line 302402) - -### What they do - -`CPartArray::Update` is a thin wrapper that calls `CSequence::update(&this->sequence, dt, frame)`. - -`CSequence::update`: -- If `anim_list.head_ != 0`: calls `update_internal` (advance cursor through anim chain) AND `apricot` (drop completed anims). **Apricot does NOT call apply_physics.** The local frame writes happen INSIDE `update_internal` via its own `apply_physics` calls. -- Else (no anims queued): calls `apply_physics(this, frame, dt, dt)` directly with the current `sequence.velocity` / `sequence.omega`. - -This means: **even with no animation queued, the sequencer keeps emitting baked velocity** (the cycle's resting motion). This is how a stationary idle character could still tick velocity. - -### What CPartArray::UpdateParts is NOT - -`CPartArray::UpdateParts(arr, frame)` at `0x005190f0` (line 287281) is a separate function that COMPOSES per-part frames from the current `AnimFrame.frame` array against the parent `frame` argument. It is called from a different path — `CPhysicsObj::UpdateChild` and `CPartArray::SetFrame` — not from the per-tick UpdateObjectInternal pipeline. - ---- - -## 12. Compact pseudocode of the whole pipeline - -```c -void CPhysicsObj::UpdateObjectInternal(float dt) { - if (transient_state high bit set /* dormant */) { - ParticleManager::UpdateParticles(); - ScriptManager::UpdateScripts(); - return; - } - - if (cell == 0) return; - - if (transient_state[1] & 1) set_ethereal(0, 0); - jumped_this_frame = 0; - - Frame candidate = identity; Frame::cache(&candidate); - - UpdatePositionInternal(dt, &candidate); - // After this: candidate = m_position.frame ⊗ animRoot ⊗ posMgrAdjust ⊗ physicsEuler - - if (has_collision_sphere) { - if (state[1] & 1) - Frame::set_vector_heading(&candidate, dir_of_translation); - else if ((state & ScaledVel) && !is_zero(m_velocityVector)) - Frame::set_heading(&candidate, get_heading(m_velocityVector)); - - Position toPos = { vtable=Position, frame=candidate, cell=current }; - CTransition* t = transition(&m_position, &toPos, 0); - if (t == null) { - set_frame(&candidate); // fall back: commit unswept candidate - cached_velocity = (0,0,0); - } else { - cached_velocity = (t.curr_pos - m_position) / dt; - SetPositionInternal(t); // commits curr_pos, updates contact plane, walkable, etc. - } - } else { - // No sphere — commit candidate directly. - if (movement_manager == null && (transient_state & 2)) - transient_state &= ~0x80; - set_frame(&candidate); - cached_velocity = (0,0,0); - } - - DetectionManager::CheckDetection(); - TargetManager::HandleTargetting(); - MovementManager::UseTime(); - CPartArray::HandleMovement(); - PositionManager::UseTime(); - ParticleManager::UpdateParticles(); - ScriptManager::UpdateScripts(); -} - -void CPhysicsObj::UpdatePositionInternal(float dt, Frame* outFrame) { - Frame local = identity; Frame::cache(&local); - - if (!(state[1] & 0x40 /* Frozen */)) { - if (part_array) - CPartArray::Update(part_array, dt, &local); - // → CSequence::apply_physics writes: - // local.origin += dt * sequence.velocity - // Frame::rotate(&local, dt * sequence.omega) - } - - if (position_manager) - PositionManager::adjust_offset(position_manager, &local, dt); - - Frame::combine(outFrame, &m_position.frame, &local); - // outFrame.origin = m_position.origin + m_position.m_fl2gv * local.origin - // outFrame.quat = m_position.quat * local.quat - - if (!(state[1] & 0x40)) - UpdatePhysicsInternal(dt, outFrame); - - process_hooks(); -} - -void CPhysicsObj::UpdatePhysicsInternal(float dt, Frame* frame) { - float v2 = velocity.x² + velocity.y² + velocity.z²; - if (v2 > 0) { - if (v2 > 50²) { // terminal speed cap - normalize(velocity); velocity *= 50; - } - calc_friction(dt, v2); - if (v2 < 0.25² + ε) // deadband - velocity = 0; - // Euler position step - frame.origin += dt * velocity + 0.5 * dt² * acceleration; - } else if (movement_manager == null && (transient_state & 2)) { - transient_state &= ~0x80; - } - - velocity += dt * acceleration; // ALWAYS runs (gravity accumulation) - Vector3 dtOmega = dt * omegaVector; - Frame::grotate(frame, &dtOmega); // ALWAYS runs -} -``` - ---- - -## 13. Cross-reference: acdream `GameWindow.cs` ~6428+ - -### What acdream does today (legacy / non-env-var path) - -```csharp -// Lines 6488-6650 (annotated): -1. Force OnWalkable + Contact + Active. -2. apply_current_movement → body.Velocity = sequencer.GetStateVelocity (rotated by Orientation). -3. body.Omega = 0. -4. If ObservedOmega non-zero: integrate Orientation manually (from server-derived rate). -5. body.calc_acceleration(). -6. body.UpdatePhysicsInternal(dt). ← Euler-step on body.Position -7. ResolveWithTransition(prePos, postPos) ← BSP/terrain sweep -8. Body.Position = resolveResult.Position. -``` - -### Mismatches with retail - -| # | Retail | acdream | Impact | -|---|--------|---------|--------| -| 1 | Locomotion via **sequencer baked velocity** consumed by `CSequence::apply_physics` (writes to local frame) | Locomotion via **`body.Velocity` = `apply_current_movement` output** consumed by Euler step in `UpdatePhysicsInternal` | acdream double-counts: sequencer gets ticked AND body.Velocity is set. If both contribute, motion overshoots. If only the body path runs (sequencer Advance returns frames but doesn't drive translation), animation plays without baked-velocity contribution. | -| 2 | Order: anim-root → posMgr-offset → **combine with world pose** → physics Euler **on composed frame** → collision sweep on composed frame | Order: apply_current_movement (sets velocity) → manual omega-integrate orientation → physics Euler on body (independent of frame composition) → collision sweep | Acceleration term `0.5 * dt² * a` is applied to body.Position, but in retail it's applied to the *post-combine* frame. For grounded remotes (`acceleration ≈ 0`) the difference is small; for jumping/falling remotes the half-accel term is in the wrong reference frame. | -| 3 | `m_velocityVector` for walking remotes ≈ **0** | `body.Velocity` for walking remotes = world-rotated locomotion vector (e.g. ~4 m/s) | Direct port question: should `body.Velocity` be zero for grounded locomotion, with motion coming entirely from sequencer baked velocity? **Yes per retail.** This is the L.3 fix. | -| 4 | `Frame::grotate` always runs in UpdatePhysicsInternal using `m_omegaVector` | `body.Omega = 0` to skip integration; manual quat rotate from `ObservedOmega` outside body | acdream's manual path is functionally equivalent provided `ObservedOmega` is the right rate; retail's path uses the body's own omega field. Replacing acdream's manual integration with `body.Omega = derived_rate` and letting `UpdatePhysicsInternal` do `grotate` would be more retail-faithful. | -| 5 | Velocity-deadband at `\|v\| < 0.25` and terminal-cap at `\|v\| < 50` are **inside** UpdatePhysicsInternal | Not implemented (or implemented at higher level) | Minor; only matters for friction-decayed bodies and ragdoll-speed clamps. | -| 6 | Translation step is **GATED on velocity² > 0** | acdream Euler-integrates unconditionally | Means a stationary remote with non-zero acceleration (gravity) gets a Z-step in acdream that retail would skip until velocity itself becomes non-zero (gravity makes velocity.z negative on the **next** velocity-update step). 1-tick lag in retail; immediate in acdream. | -| 7 | `set_frame` validates the frame and resets-to-identity if quat is invalid | acdream commits without explicit IsValid check | NaN-quat protection missing; can't be triggered in normal play but a single packet-decode bug could stick a remote with a corrupted orientation forever. | -| 8 | Anim-frame combine + posMgr-offset happen **before** physics integration | acdream skips the local-frame compose entirely; physics on body.Position is independent | This is THE structural mismatch driving L.3. | - -### Why the L.2 PositionManager attempt regressed (per CLAUDE.md note) - -The note says: -> the env-var path drops the per-tick collision sweep (ResolveWithTransition) that the default path retains, causing a visible "staircase" pattern when remotes run up/down slopes - -Retail does NOT drop ResolveWithTransition — `transition()` runs every tick after UpdatePositionInternal builds the candidate. The bug was integrating PositionManager but skipping `transition()`/`SetPositionInternal` in the env-var branch. Retail's structure makes both mandatory. - -### The shape L.3 should produce - -```csharp -void TickRemote(rm, dt) { - // 1. Build candidate frame in local space. - Frame local = Frame.Identity; - sequencer.ApplyPhysicsToFrame(ref local, dt); // ← retail apply_physics: writes baked velocity & omega - positionMgr.AdjustOffset(ref local, dt); // null for normal remotes; placeholder for L.4 - - // 2. Compose with world pose. - Frame candidate = Frame.Combine(rm.Body.WorldFrame, local); - - // 3. Physics integration on the composed frame (gravity, knockback, terminal cap). - rm.Body.UpdatePhysicsInternal(dt, ref candidate); // mutates m_velocity, candidate.origin (only if v² > 0), candidate.quat (always) - - // 4. Collision sweep candidate → resolved. - if (rm.Body.HasCollisionSphere && candidate.Origin != rm.Body.Position) { - var transition = _physicsEngine.Transition(rm.Body.WorldPos, candidate, rm.CellId); - if (transition == null) { - rm.Body.SetFrame(candidate); // fall back to unswept candidate - rm.CachedVelocity = Vector3.Zero; - } else { - rm.CachedVelocity = (transition.CurrPos - rm.Body.WorldPos) / dt; - rm.Body.SetPositionFromTransition(transition); // commits frame, contact_plane, walkable, sliding_normal - } - } else { - rm.Body.SetFrame(candidate); - } - - // 5. Per-tick managers. - rm.DetectionManager?.Check(); - rm.TargetManager?.Tick(); - rm.MovementManager?.UseTime(); - rm.PartArray.HandleMovement(); - rm.PositionManager?.UseTime(); - rm.ParticleManager?.Update(); - rm.ScriptManager?.Update(); -} -``` - -The key insight: **for walking remotes, `m_velocityVector` stays zero, locomotion enters via the sequencer's `apply_physics` writing into `local`, and `Frame::combine` rotates that body-local vector into world space using the current orientation.** This matches CSequence::apply_physics's documented behavior and matches what we observe — animation cycles produce locomotion *because the cycle's MotionData has baked velocity*, not because the network hands us a velocity. - ---- - -## 14. Open questions for L.3 implementation - -1. **Where does omega come from for remotes?** Retail's `m_omegaVector` is set by `CMotionInterp::DoInterpretedMotion` via the cycle's MotionData (TurnLeft/TurnRight bake omega). Our local player path already has this; verify the remote path reads from the same source instead of `ObservedOmega` (which is server-derived and lossy). - -2. **Does CSequence::apply_physics always run, even on idle?** Re-reading line 302413-302419: if `anim_list.head_ == 0` AND `arg3 != 0`, apply_physics runs with the *current* `sequence.velocity` / `sequence.omega`. Idle cycles have zero baked velocity → apply_physics is a no-op. So idle remotes get no spurious motion. - -3. **Frame.IsValid check in SetFrame**: minor robustness item. Worth adding when porting set_frame. - -4. **Friction**: `calc_friction` gets called inside UpdatePhysicsInternal whenever velocity² > 0. We don't currently port friction. For grounded velocity-driven motion (knockbacks) it matters; for animation-driven motion (m_velocityVector ≈ 0) it doesn't. - -5. **PhysicsBody.update_object's MinQuantum gate**: GameWindow.cs ~6633 has a long comment explaining why it bypasses `update_object` and calls `UpdatePhysicsInternal` directly. Retail's `UpdateObjectInternal` is the entry; our top-level entry mirrors that. The 30 Hz quantum gate is in retail (see `MinQuantum=1/30s`); retail addresses it by NOT subticking — `UpdateObjectInternal` is called at the engine tick rate (~30 Hz for retail's render loop, see CLAUDE.md retail debugger notes). Our 60 Hz tick may want a sub-step gate or to halve `dt` per accumulated frame to match retail integration cadence. diff --git a/docs/research/2026-05-04-l3-port/02-um-handling.md b/docs/research/2026-05-04-l3-port/02-um-handling.md deleted file mode 100644 index 52336918..00000000 --- a/docs/research/2026-05-04-l3-port/02-um-handling.md +++ /dev/null @@ -1,1206 +0,0 @@ -# L.3 port — UpdateMotion (0xF74C) handling pipeline - -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` -(Sept 2013 EoR build, 18,366 named functions). All line numbers are -into that file unless otherwise noted. - -This document traces the full inbound path from the wire (the 0xF74C -packet hitting the network layer) down to body velocity and animation -state. It also covers the OUTBOUND path the local player uses (so we -know what acdream's `+Acdream` sends, and what an observing retail -client receives via the SAME entry point but for a different guid). - ---- - -## 0. Top-level flow (one-line summary) - -``` -0xF74C wire packet - └── CM_Physics::DispatchSB_* (357214) — picks branch by opcode - └── CPhysics::SetObjectMovement (271370) — staleness check, exit-29-style timestamps - └── CPhysicsObj::unpack_movement (280179) — lazy-creates MovementManager - └── MovementManager::unpack_movement (300563) — reads MovementType byte + params - ├── case 0 (RawCommand) → move_to_interpreted_state - ├── case 6 (MoveToObject) → MoveToManager.MoveToObject - ├── case 7 (MoveToPosition) → MoveToManager.MoveToPosition - ├── case 8 (TurnToObject) → MoveToManager.TurnToObject - └── case 9 (TurnToHeading) → MoveToManager.TurnToHeading - -InterpretedMotionState delivered by case 0 then drives: - CMotionInterp::move_to_interpreted_state (305936) - ↓ copy_movement_from + apply_current_movement - ↓ apply_interpreted_movement (305713) — re-runs DoInterpretedMotion for each axis - ↓ DoInterpretedMotion → contact_allows_move? + ApplyMotion → add_to_queue - ↓ get_state_velocity (305160) → CPhysicsObj.set_local_velocity -``` - -Outbound (when local player presses W or releases W): - -``` -W press (or release) - └── CommandInterpreter::SendMovementEvent (700274) - └── MoveToStatePack ctor (RawMotionState snapshot of player) - └── ACCmdInterp::SendMoveToStateEvent → 0xF61C MoveToState packet -``` - -That single 0xF61C goes to the server. ACE relays the player's wire -state to nearby observers as 0xF74C UpdateMotion. So the observer -side is the same code path described in §1–§7 below, just with the -remote player's guid. - ---- - -## 1. The 0xF74C dispatcher - -**Function:** `CM_Physics::DispatchSB_<...>` — opcode switch. -**Address:** `0x005595d0` (containing line 357211 reference). - -Verbatim retail (357211–357240): - -```c -00559605 } -005595ff } -005595ff else if (((char*)ecx - 0xf74c) <= 0x8f) -005597b8 switch (ecx) -005597b8 { -00559850 case 0xf74c: -00559850 { -00559850 uint32_t ebp_2 = *(uint32_t*)(buf_ + 4); // object guid -00559852 class CObjectMaint* m_pObjMaint = this->m_pObjMaint; -0055985c arg2 = &buf_[8]; // payload start -00559860 class CPhysicsObj* eax_25 = CObjectMaint::GetObjectA(m_pObjMaint, ebp_2); -00559867 class NetBlob* eax_26 = arg2; -0055986d uint16_t ecx_15 = eax_26->vtable; // instance_timestamp -00559875 arg2 = (&eax_26->vtable + 2); // skip 2 bytes -00559875 -0055987d if ((eax_25 != 0 && CPhysicsObj::is_newer(eax_25->update_times[8], ecx_15) == 0)) -0055987d { -00559896 int32_t eax_29; -00559896 eax_29 = eax_25->update_times[8]; -005598a2 if (eax_29 != ecx_15) -005598e8 return 2; // STALE → drop -005598bc if (CPhysics::SetObjectMovement(this->physics, eax_25, arg2, bufSize_) != 0) -005598be this->cmdinterp->vtable->LoseControlToServer(); -005598d7 return 1; -0055987d } -0055987d -005598ef SmartBox::QueueBlobForObject(this, ebp_2, ebx); // entity not yet known → queue -00559902 return 4; -``` - -**Behavior:** - -1. Read object guid from offset 4. -2. Read 2-byte `instance_timestamp` from payload start (offset 8). -3. Look up the entity. If we don't know it, queue the blob and return. -4. If `update_times[8]` (last seen instance ts) is newer than the - wire's, **drop the packet (return 2)**. This is the staleness gate. -5. Otherwise hand off the rest of the payload to - `CPhysics::SetObjectMovement`. - ---- - -## 2. CPhysics::SetObjectMovement - -**Function:** `CPhysics::SetObjectMovement` -**Address:** `0x00509690` -**Lines:** 271370–271431. - -```c -00509690 int32_t __stdcall CPhysics::SetObjectMovement(class CPhysics* this @ ecx, - class CPhysicsObj* arg2, // entity - void* arg3, // payload pointer - uint32_t arg4, // remaining size - uint16_t arg5, // instance_timestamp (already read) - uint16_t arg6, // server_control_timestamp - int32_t arg7) // forceTeleport flag -{ - int32_t ebx = 0; - if (weenie_obj != 0) - ebx = weenie_obj->vtable->IsThePlayer(); // is this the local player? - - weenie_obj = arg2->update_times[1]; // last instance_timestamp - int32_t edi = arg5; - // ... unsigned 16-bit "is wire newer?" comparison via 0x7fff-wrap ... - if (-((eax_7 - eax_7)) != 0) // i.e. newer - { - arg2->update_times[1] = edi; // record new instance ts - weenie_obj = arg2->update_times[5]; // last server_control_ts - edi = arg6; - // ... same wrap compare on server_control_ts ... - if (-((eax_14 - eax_14)) != 0) - return 0; // stale on server_control_ts - arg2->update_times[5] = edi; - - if ((arg7 == 0 || ebx == 0)) // not "force teleport on player" - { - arg2->last_move_was_autonomous = arg7; - CPhysicsObj::unpack_movement(arg2, &arg3, arg4); - if (ebx != 0) return 1; // local player echo: ask cmdinterp to LoseControl - } - } - return 0; -} -``` - -**Key behaviors:** - -- Two timestamp gates (instance_ts and server_control_ts) before any - state change — both use 16-bit wrap-aware ordering. -- For the **local player** (`IsThePlayer != 0`), if the timestamps are - newer AND `forceTeleport == 0`, returns 1 — the dispatcher then - calls `LoseControlToServer()`. This is how a server overrides the - local player's prediction (e.g. teleport, frozen, etc). -- For everyone else (remote players, NPCs, monsters), returns 0 and - proceeds to `unpack_movement`. - ---- - -## 3. CPhysicsObj::unpack_movement — lazy creates MovementManager - -**Function:** `CPhysicsObj::unpack_movement` -**Address:** `0x00512040` -**Lines:** 280179–280203. - -```c -00512040 void __thiscall CPhysicsObj::unpack_movement(this, arg2, arg3) { - if (this->movement_manager == 0) { - this->movement_manager = MovementManager::Create(this, this->weenie_obj); - // first creation also touches transient_state (sets bit 0x80) - } - MovementManager::unpack_movement(this->movement_manager, arg2, arg3); -} -``` - -Pure dispatch. The interesting work happens in MovementManager. - ---- - -## 4. MovementManager::unpack_movement — the actual wire reader - -**Function:** `MovementManager::unpack_movement` -**Address:** `0x00524440` -**Lines:** 300563–300704. - -This is the **real entry point for UM payload parsing**. It reads a -2-byte `MovementType` discriminator and a 2-byte initial style, then -branches. - -Verbatim core (300563–300668): - -```c -00524440 int32_t __thiscall MovementManager::unpack_movement(this, arg2, arg3) -{ - if (this->motion_interpreter != 0) - { - if (physics_obj != 0) - { - CPhysicsObj::interrupt_current_movement(physics_obj); - CPhysicsObj::unstick_from_object(this->physics_obj); - // ... Frame::cache(local_origin) ... - // var_9c = MovementParameters() with defaults - // var_28 = InterpretedMotionState() with defaults - - void* eax_1 = *arg2; - int16_t ecx_4 = *(uint16_t*)eax_1; // (a) movement_type byte - *arg2 = eax_1 + 2; - uint32_t ebp_1 = (uint32_t)ecx_4; // movement_type - - ecx_4 = *(uint16_t*)(eax_1 + 2); // (b) style 16-bit MotionCommand low - *arg2 = eax_1 + 4; - uint32_t ecx_5 = command_ids[(uint32_t)ecx_4]; // expand to full uint32 cmd - - // If the new style differs from current, fire DoMotion(style, default_params) - // — that switches the body's currentStyle (combat→peace etc). - if (CBaseFilter::GetPinVersion(this->motion_interpreter) != ecx_5) - CMotionInterp::DoMotion(this->motion_interpreter, ecx_5, &var_9c); - - switch (ebp_1) { - case 0: // RawCommand (the bulk of UMs) - InterpretedMotionState::UnPack(&var_28, arg2, arg3); - uint32_t ebx_3 = 0; - if ((var_a4_1 & 0x100) != 0) // bit indicates "stick to object" guid present - { - uint32_t* eax_8 = *arg2; - ebx_3 = *eax_8; // guid to stick to - *arg2 = &eax_8[1]; - } - MovementManager::move_to_interpreted_state(this, &var_28); - if (ebx_3 != 0) - CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); - this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); - return 1; - - case 6: /* MoveToObject — guid + Position + MovementParameters + runRate */ - case 7: /* MoveToPosition — Position + MovementParameters + runRate */ - case 8: /* TurnToObject — guid + heading + MovementParameters */ - case 9: /* TurnToHeading — MovementParameters */ - // each delegates to MoveToManager::* (out of scope here) - } - } - } - return 0; -} -``` - -**Reads from wire (case 0 only):** - -- 2 bytes — `movement_type` (0=RawCommand, 6/7/8/9=MoveTo variants). -- 2 bytes — initial currentStyle (16-bit MotionCommand low → expanded - to full uint32 via `command_ids[]` lookup). -- `InterpretedMotionState::UnPack` (see §5) consumes the rest. - -**Writes to MotionInterp:** - -- If the new style differs, `DoMotion(style, default_params)` runs - immediately — this is how stance changes (Combat ↔ Peace) occur. -- Then `move_to_interpreted_state` bulk-applies the unpacked state - (see §7). -- `standing_longjump` flag set from the high bit (0x200) of - movement_type. - -**Note:** Type 0 is what the ACE relay produces for nearly every -locomotion event. Types 6–9 are server-controlled MoveTo's (e.g. -"NPC walks to point X"). The MoveTo branches end up calling -`MoveToManager::MoveToObject`/`...Position`/`TurnToHeading` which is -its own state machine — out of scope for this doc. - ---- - -## 5. InterpretedMotionState::UnPack — flag-driven field reader - -**Function:** `InterpretedMotionState::UnPack` -**Address:** `0x0051f400` -**Lines:** 294360–294523. - -This reads a single `uint32_t` flag word and conditionally unpacks 13 -fields. **This is exactly the format ACE writes when relaying.** - -Verbatim core (294360–294492): - -```c -0051f400 int32_t __thiscall InterpretedMotionState::UnPack(this, arg2, arg3) -{ - InterpretedMotionState::Destroy(this); // clear actions list - - uint32_t edx; - if (arg3 < 4) edx = arg3; - else { - edx = *(uint32_t*)(*arg2); // FLAGS uint32 - *arg2 += 4; - } - - if ((edx & 0x01) == 0) this->current_style = 0x8000003d; // NonCombat - else { read uint16, expand via command_ids[] → current_style } - - if ((edx & 0x02) == 0) this->forward_command = 0x41000003; // Ready - else { read uint16, expand → forward_command } - - if ((edx & 0x08) == 0) this->sidestep_command = 0; - else { read uint16, expand → sidestep_command } - - if ((edx & 0x20) == 0) this->turn_command = 0; - else { read uint16, expand → turn_command } - - if ((edx & 0x04) == 0) this->forward_speed = 1.0f; - else { this->forward_speed = *(float*)(*arg2); *arg2 += 4; } - - if ((edx & 0x10) == 0) this->sidestep_speed = 1.0f; - else { this->sidestep_speed = *(float*)(*arg2); *arg2 += 4; } - - if ((edx & 0x40) == 0) this->turn_speed = 1.0f; - else { this->turn_speed = *(float*)(*arg2); *arg2 += 4; } - - int32_t i_4 = (edx >> 7) & 0x1f; // action count (5 bits) - while (i_4-- > 0) { - // each action: uint16 motion → command_ids[], uint32 speed, - // uint16 stamp+autonomous bit (0x7fff stamp; 0x8000 autonomous) - InterpretedMotionState::AddAction(this, motion, speed, stamp, autonomous); - } - - align_ptr_to_4(); - return 1; -} -``` - -**Key facts (this is THE definitive flag layout):** - -| Bit | Field | When CLEAR | -|----|----|----| -| 0x01 | current_style | defaults to NonCombat (0x8000003d) | -| 0x02 | forward_command | defaults to Ready (0x41000003) | -| 0x04 | forward_speed | defaults to 1.0f | -| 0x08 | sidestep_command | defaults to 0 | -| 0x10 | sidestep_speed | defaults to 1.0f | -| 0x20 | turn_command | defaults to 0 | -| 0x40 | turn_speed | defaults to 1.0f | -| 0x80–0x800 | action count (5 bits) | 0 | - -**Crucial corollary for the L.3 port:** when ACE omits a field on the -wire (e.g. doesn't set bit 0x02 because forward_command was -"Invalid" — its idle), the decompiled UnPack DEFAULTS that field to -the table-default value (Ready / 0 / 1.0f). This is **NOT** "preserve -previous." It's "reset to the per-axis default." That's why a stop -broadcast looks like an UM with all command bits cleared. - -The wire's `forward_command = 0` (clear bit 0x02) IS the stop signal. -The unpacker maps it to Ready. - ---- - -## 6. command_ids[] — 16-bit → 32-bit motion expansion - -`command_ids[]` is a static lookup table that takes the 16-bit -MotionCommand low word and returns the full uint32 (with class byte -reattached). This is how a wire `0x0007` (RunForward low) becomes -`0x44000007` (RunForward full). acdream's -`MotionCommandResolver.ReconstructFullCommand` is the equivalent. - ---- - -## 7. CMotionInterp::move_to_interpreted_state - -**Function:** `CMotionInterp::move_to_interpreted_state` -**Address:** `0x005289c0` -**Lines:** 305936–305992. - -Verbatim: - -```c -005289c0 int32_t __thiscall CMotionInterp::move_to_interpreted_state(this, arg2) -{ - if (physics_obj == 0) return 0; - - this->raw_state.current_style = arg2->current_style; - CPhysicsObj::interrupt_current_movement(physics_obj); - uint32_t eax_2 = motion_allows_jump(this, this->interpreted_state.forward_command); - int32_t esi_1 = -eax_2; - InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // ← bulk copy - apply_current_movement(this, 1, -((esi_1 - esi_1))); // cancelMoveTo=1, allowJump=stillAllowed - - MovementParameters var_2c; - MovementParameters::MovementParameters(&var_2c); - - for (LListData* i = arg2->actions.head_; i != 0; i = i->llist_next) - { - // 15-bit action stamp comparison (wrap-aware via 0x7fff) - int32_t actStamp = *(int32_t*)((char*)i + 0xc) & 0x7fff; - int32_t serverStamp = this->server_action_stamp & 0x7fff; - int32_t delta = abs(actStamp - serverStamp); - bool isNewer = (delta <= 0x3fff) ? (serverStamp < actStamp) : (actStamp < serverStamp); - - if (isNewer) - { - // gate: only fire actions that came from the network (autonomous=0) - // when this is a player; for NPCs always fire. - if (weenie_obj == 0 || weenie_obj->vtable->IsThePlayer() == 0 - || *(int32_t*)((char*)i + 0x10) == 0 /*autonomous bit*/) - { - this->server_action_stamp = *(int32_t*)((char*)i + 0xc); - var_2c.action_stamp = *(int32_t*)((char*)i + 8); - // var_28 |= 0x1000 = ModifyInterpretedState - CMotionInterp::DoInterpretedMotion(this, *(int32_t*)((char*)i + 4), &var_2c); - } - } - } - return 1; -} -``` - -**Critical facts:** - -1. **`copy_movement_from` is UNCONDITIONAL bulk copy** (lines - 293301–293311) — every field of InterpretedState is overwritten: - `current_style`, `forward_command`, `forward_speed`, - `sidestep_command`, `sidestep_speed`, `turn_command`, `turn_speed`. - No filter by stance change, no diff, no per-axis gate. Whatever - the wire said (post-defaults from UnPack) is now the body's state. - -2. After the copy, **`apply_current_movement(cancelMoveTo=true, allowJump)`** - re-runs the full state machine. This is where the body's velocity - gets re-derived from the new InterpretedState (see §8). - -3. The actions list is iterated separately with stamp-wrap protection - so we don't replay actions we already saw, and we skip player-self - echoes that we ourselves originated (autonomous=true). - ---- - -## 8. apply_current_movement → apply_interpreted_movement - -**Function:** `CMotionInterp::apply_interpreted_movement` -**Address:** `0x00528600` -**Lines:** 305713–305788. - -```c -00528600 void apply_interpreted_movement(this, arg2 /*cancelMoveTo*/, arg3 /*allowJump*/) -{ - if (physics_obj != 0) - { - MovementParameters var_2c; - MovementParameters::MovementParameters(&var_2c); - - // If forward is RunForward, cache the speed as MyRunRate - if (this->interpreted_state.forward_command == 0x44000007) - this->my_run_rate = this->interpreted_state.forward_speed; - - // Re-fire DoInterpretedMotion(currentStyle) — re-applies stance - DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c); - - if (contact_allows_move(this->interpreted_state.forward_command) == 0) - { - // Body is airborne / dead — force Falling - DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c); - } - else - { - if (this->standing_longjump != 0) - { - DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); - StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); - } - else - { - // FORWARD axis - var_2c.speed = this->interpreted_state.forward_speed; - DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c); - - // SIDESTEP axis - if (this->interpreted_state.sidestep_command == 0) - StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); - else - { - var_2c.speed = this->interpreted_state.sidestep_speed; - DoInterpretedMotion(this, this->interpreted_state.sidestep_command, &var_2c); - } - } - } - - // TURN axis - if (this->interpreted_state.turn_command != 0) { - var_2c.speed = this->interpreted_state.turn_speed; - DoInterpretedMotion(this, this->interpreted_state.turn_command, &var_2c); - return; - } - // No turn — explicit stop - uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c); - if (eax_10 == 0) { - add_to_queue(this, var_c, 0x41000003, 0); - // remove TurnRight from action queue - } - } -} -``` - -**This is the per-tick re-apply.** Every time the wire delivers a new -state (or we land, or we leave ground), this function fires -`DoInterpretedMotion` for each axis (forward, sidestep, turn) so the -physics body re-derives velocity. Velocity comes from -`get_state_velocity` which lives inside `DoInterpretedMotion` → -`CPhysicsObj::DoInterpretedMotion` (not shown in detail here, but -the chain runs through `set_local_velocity`). - ---- - -## 9. CMotionInterp::DoMotion (raw command path) - -**Function:** `CMotionInterp::DoMotion` -**Address:** `0x00528d20` -**Lines:** 306159–306217. - -```c -00528d20 uint32_t DoMotion(this, arg2 /*motion*/, arg3 /*MovementParameters*/) -{ - if (physics_obj == 0) return 8; - - uint32_t ebp = arg2; - // ... copy struct fields locally ... - - if (params->__inner0.byte1 < 0) // CancelMoveTo bit - CPhysicsObj::interrupt_current_movement(physics_obj); - - if ((params->__inner0.byte1 & 8) != 0) // SetHoldKey bit - SetHoldKey(this, params->hold_key_to_apply, ((__inner0 >> 0xf) & 1)); - - adjust_motion(this, &arg2, &speed, params->hold_key_to_apply); - - if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) { - if (ebp == 0x41000012 /*Crouch*/) return 0x3f; // CantCrouchInCombat - if (ebp == 0x41000013 /*Sit*/) return 0x40; - if (ebp == 0x41000014 /*Sleep*/) return 0x41; - if ((ebp & 0x2000000) != 0) return 0x42; // CantChatEmoteInCombat - } - - if ((ebp & 0x10000000 /*Action*/) != 0 - && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6) - return 0x45; // TooManyActions - - uint32_t result = DoInterpretedMotion(this, arg2, &var_2c); - - if (result == 0 && (params->__inner0.byte1 & 0x20 /*ModifyRawState*/) != 0) - RawMotionState::ApplyMotion(&this->raw_state, ebp, arg3); - - return result; -} -``` - -**Behavior:** - -- `adjust_motion` (see §10) folds HoldKey + sign-flipped commands. -- Combat-style guards reject Crouch/Sit/Sleep/ChatEmote. -- Action-class commands are queued, max 6 outstanding. -- All work delegates to `DoInterpretedMotion` (§11). -- If caller asked, the raw state is also updated via - `RawMotionState::ApplyMotion`. - ---- - -## 10. CMotionInterp::adjust_motion - -**Function:** `CMotionInterp::adjust_motion` -**Address:** `0x00528010` -**Lines:** 305343–305400. - -This is **the canonical sign-flipping / hold-key application function**. -Verbatim core: - -```c -00528010 void adjust_motion(this, uint32_t* motion, float* speed, HoldKey holdKey) -{ - if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) return; - - uint32_t cmd = *motion; - - if (cmd == 0x6500000e /*SideStepLeft*/) { - *motion = 0x6500000f /*SideStepRight*/; - *speed *= -1.0f; - } - else if (cmd == 0x65000010 /*TurnLeft???*/) { // really an alias - *motion = 0x6500000f; - *speed *= -1.0f; - } - else if (cmd == 0x45000006 /*WalkBackward*/) { - *motion = 0x45000005 /*WalkForward*/; - *speed *= -0.65f; // BackwardsFactor - } - // (RunForward 0x44000007 falls through unchanged) - - // Sidestep gets its own scale factor - if (*motion == 0x6500000f /*SideStepRight*/) { - *speed = (3.12f / 1.25f) * 0.5f * (*speed); // = 1.248 - } - - if (holdKey == HoldKey_Invalid) - holdKey = this->raw_state.current_holdkey; - - if (holdKey == HoldKey_Run) - apply_run_to_command(this, motion, speed); -} -``` - -**Mappings produced by adjust_motion:** - -| Input motion | Input speed | Output motion | Output speed | -|---|---|---|---| -| WalkForward | s | WalkForward | s | -| WalkBackward | s | WalkForward | -0.65 × s | -| TurnLeft | s | TurnRight | -s | -| SideStepLeft | s | SideStepRight | -s | -| SideStepRight (final) | s | SideStepRight | 1.248 × s | -| RunForward | s | RunForward | s | - -Then if HoldKey == Run, `apply_run_to_command` fires. - ---- - -## 11. CMotionInterp::apply_run_to_command - -**Function:** `CMotionInterp::apply_run_to_command` -**Address:** `0x00527be0` -**Lines:** 305062–305123. - -```c -00527be0 void apply_run_to_command(this, uint32_t* motion, float* speed) -{ - long double speedMod; - - if (weenie_obj != 0) { - if (weenie_obj->InqRunRate(&speedMod) != 0) { - // speedMod taken from weenie InqRunRate output - } else { - speedMod = (long double)this->my_run_rate; - } - } else { - speedMod = 1.0L; - } - - uint32_t cmd = *motion; - - if (cmd == 0x45000005 /*WalkForward*/) { - if (*speed > 0.0f) - *motion = 0x44000007 /*RunForward*/; // PROMOTION - *speed = (float)(speedMod * (*speed)); - return; - } - - if (cmd == 0x6500000d /*TurnRight*/) { - *speed = (float)(1.5f * (*speed)); // RunTurnFactor - return; - } - - if (cmd == 0x6500000f /*SideStepRight*/) { - speedMod *= (long double)*speed; - *speed = (float)speedMod; - if (fabsl(speedMod) > 3.0L) { // MaxSidestepAnimRate - *speed = (speedMod > 0) ? 3.0f : -3.0f; - } - } -} -``` - -**Critical asymmetry — the speed > 0.0 gate:** - -```c -if (*speed > 0.0f) - *motion = 0x44000007 /*RunForward*/; -``` - -This is the line that prevents `WalkBackward + HoldKey.Run` from -becoming `RunBackward`. After `adjust_motion` flips WalkBackward → -WalkForward with negative speed, this gate keeps the motion as -WalkForward (because speed ≤ 0) and the speed multiplication still -applies the runRate. - -So sign-flipped backward arrives at `get_state_velocity` as: -- `forward_command = WalkForward (0x45000005)` -- `forward_speed = -0.65 × runRate` (negative) - -Then `get_state_velocity` (next section) hits the WalkForward branch -and produces a NEGATIVE `velocity.Y` — the body moves backward at -walk-pace × 65% × runRate. - ---- - -## 12. CMotionInterp::get_state_velocity - -**Function:** `CMotionInterp::get_state_velocity` -**Address:** `0x00527d50` -**Lines:** 305160–305204. - -```c -00527d50 void get_state_velocity(this, AC1Legacy::Vector3* out) -{ - long double vx; - if (this->interpreted_state.sidestep_command != 0x6500000f) - vx = 0.0L; - else - vx = 1.25L * (long double)this->interpreted_state.sidestep_speed; - out->x = (float)vx; - - long double vy; - uint32_t fwd = this->interpreted_state.forward_command; - if (fwd == 0x45000005 /*WalkForward*/) - vy = 3.12L * (long double)this->interpreted_state.forward_speed; - else if (fwd == 0x44000007 /*RunForward*/) - vy = 4.0L * (long double)this->interpreted_state.forward_speed; - else - vy = 0.0L; - out->y = (float)vy; - - out->z = 0.0f; - - // Cap to maxSpeed = 4.0 * runRate - long double rate = this->my_run_rate; /* or InqRunRate */ - long double len = sqrtl(vx*vx + vy*vy + 0.0L); - if (len > 4.0L * rate) { - long double scale = (4.0L * rate) / len; - out->x *= (float)scale; - out->y *= (float)scale; - } -} -``` - -**Hard-coded constants (these match ACE 1:1):** - -- `WalkAnimSpeed = 3.12 m/s` -- `RunAnimSpeed = 4.0 m/s` -- `SidestepAnimSpeed = 1.25 m/s` -- `MaxSidestepAnimRate = 3.0` (clamp inside apply_run_to_command) -- `BackwardsFactor = 0.65` -- `RunTurnFactor = 1.5` - -Velocity output is body-local: X = strafe (right positive), -Y = forward (forward positive), Z = 0. Z gets composed by gravity / -LeaveGround in CPhysicsObj. - ---- - -## 13. CMotionInterp::contact_allows_move - -**Function:** `CMotionInterp::contact_allows_move` -**Address:** `0x00528240` -**Lines:** 305471–305505. - -```c -00528240 int32_t contact_allows_move(this, uint32_t motion) -{ - if (physics_obj != 0) { - if (motion > 0x40000015) { - if (motion >= 0x6500000d && motion <= 0x6500000e) // TurnRight..TurnLeft - return 1; // turns always allowed - } else if (motion == 0x40000015 /*Falling*/ || motion == 0x40000011 /*Dead*/) { - return 1; - } - - if (weenie_obj != 0 && weenie_obj->IsCreature() == 0) // non-creatures (chess pieces, etc) - return 1; - - if (physics_obj == 0 || (physics_obj->state & 0x4 /*Gravity*/) == 0) - return 1; // no gravity → always - - uint8_t ts = physics_obj->transient_state; - if ((ts & 1 /*Contact*/) != 0 && (ts & 2 /*OnWalkable*/) != 0) - return 1; // grounded - } - return 0; // airborne creature on gravity-affected body -} -``` - -Used by `DoInterpretedMotion` to decide whether a motion can take -effect right now or must be deferred. - ---- - -## 14. CMotionInterp::DoInterpretedMotion (the leaf) - -**Function:** `CMotionInterp::DoInterpretedMotion` -**Address:** `0x00528360` -**Lines:** 305575–305631. - -```c -00528360 uint32_t DoInterpretedMotion(this, motion, params) -{ - if (physics_obj == 0) return 8; - uint32_t result; - - if (contact_allows_move(this, motion) != 0) { - if (this->standing_longjump != 0 - && (motion == 0x45000005 /*Walk*/ || motion == 0x44000007 /*Run*/ || motion == 0x6500000f /*SideStep*/)) - { - // skip the engine-side action; just touch InterpretedState if asked - if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - result = 0; - } - else { - if (motion == 0x40000011 /*Dead*/) - CPhysicsObj::RemoveLinkAnimations(this->physics_obj); - - result = CPhysicsObj::DoInterpretedMotion(this->physics_obj, motion, params); - if (result == 0) { - uint32_t jumpErr; - if ((params->__inner0 & 0x20000) == 0) { - jumpErr = motion_allows_jump(this, motion); - if (jumpErr == 0 && (motion & 0x10000000 /*Action*/) == 0) - jumpErr = motion_allows_jump(this, this->interpreted_state.forward_command); - } else { - jumpErr = 0x48; /*disable*/ - } - add_to_queue(this, params->context_id, motion, jumpErr); - if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - } - } - } - else if ((motion & 0x10000000 /*Action*/) == 0) { - if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - result = 0; - } - else result = 0x24 /*YouCantJumpWhileInTheAir*/; - - if (physics_obj != 0 && physics_obj->cell == 0) - CPhysicsObj::RemoveLinkAnimations(physics_obj); - - return result; -} -``` - -This is **the function `apply_interpreted_movement` calls 3× per UM** -(once each for forward/sidestep/turn axes). Note: - -- The actual physics velocity push is inside - `CPhysicsObj::DoInterpretedMotion` — that's where - `set_local_velocity(get_state_velocity(...))` happens. -- `InterpretedMotionState::ApplyMotion` updates the InterpretedState - ONLY when the params flag 0x40 (ModifyInterpretedState) is set. - In `apply_interpreted_movement`, params is a fresh - `MovementParameters()` with default flags — and the default has - ModifyInterpretedState=0. So per-axis re-application is purely a - PHYSICS push; the state itself stays as `copy_movement_from` - bulk-loaded it. - ---- - -## 15. InterpretedMotionState::ApplyMotion - -**Function:** `InterpretedMotionState::ApplyMotion` -**Address:** `0x0051ea40` -**Lines:** 293531–293564. - -```c -0051ea40 void ApplyMotion(this, uint32_t motion, params) -{ - if (motion == 0x6500000d /*TurnRight*/) { - this->turn_command = 0x6500000d; - this->turn_speed = params->speed; - return; - } - if (motion == 0x6500000f /*SideStepRight*/) { - this->sidestep_command = 0x6500000f; - this->sidestep_speed = params->speed; - return; - } - if ((motion & 0x40000000) != 0) { // any 0x4xxxxxxx (Walk/Run/Stand/Falling/Dead/etc) - this->forward_command = motion; - this->forward_speed = params->speed; - return; - } - if (motion < 0) { // 0x8xxxxxxx — style change - this->forward_command = 0x41000003 /*Ready*/; - this->current_style = motion; - return; - } - if ((motion & 0x10000000 /*Action*/) != 0) { - AddAction(this, motion, params->speed, params->action_stamp, - (params->__inner0 >> 0xc) & 1 /*autonomous bit*/); - } -} -``` - -**This drives the state machine when the engine is *generating* -motion locally (e.g. local player, MoveTo manager).** It is NOT what -the wire-driven path uses — the wire path uses -`copy_movement_from` (§7). - -This is the function ACE's `InterpretedMotionState.ApplyMotion` -mirrors verbatim. - ---- - -## 16. Outbound: CommandInterpreter::SendMovementEvent - -**Function:** `CommandInterpreter::SendMovementEvent` -**Address:** `0x006b4680` -**Lines:** 700274–700312. - -```c -006b4680 void SendMovementEvent(this) -{ - CPhysicsObj* player = this->player; - if (player != 0 && this->smartbox != 0 - && CPhysicsObj::InqRawMotionState(player) != 0) - { - if (this->autonomy_level != 0) // CLIENT IS IN CONTROL - { - uint16_t instTs = player->update_times[8]; - int32_t ctlTs = player->update_times[4]; - int32_t teleTs = player->update_times[5]; - int32_t forceTs= player->update_times[6]; - CMotionInterp* mi = CPhysicsObj::get_minterp(player); - // contact = (Contact && OnWalkable) - int32_t contact = (player->transient_state & 1) && (player->transient_state & 2); - - MoveToStatePack pkt; - MoveToStatePack::MoveToStatePack(&pkt, - CPhysicsObj::InqRawMotionState(player), // RAW state, not interpreted - &player->m_position, - contact, - mi->standing_longjump, - instTs, teleTs, ctlTs, forceTs); - - this->vtable->SendMoveToStateEvent(&pkt); // → 0xF61C wire - this->last_sent_position_time = Timer::cur_time; - } - } -} -``` - -**Critical facts:** - -- The **OUTBOUND packet (0xF61C MoveToState) uses the RawMotionState**, - not the InterpretedMotionState. RawState carries the player's - literal input (e.g. `forward_command = WalkForward`, - `forward_holdkey = Run`) — the server (or observer) does the - promotion via its own `apply_raw_movement` → `adjust_motion` chain. -- `RawMotionState::Pack` (`0x0051ed10`, lines 293761–293980) packs a - flag word with bits matching different fields than the interpreted - one: 0x01=holdkey, 0x02=style, 0x04=fwd_cmd, 0x08=fwd_holdkey, - 0x10=fwd_speed≠1.0, 0x20=side_cmd, 0x40=side_holdkey, 0x80=side_speed≠1, - 0x100=turn_cmd, 0x200=turn_holdkey, 0x400=turn_speed≠1, then 5-bit - action count starting at 0x800. - -This means the outbound flag layout is **different** from the -inbound InterpretedMotionState layout (different bit positions, plus -holdkey fields). When the local player presses W: -- `raw_state.forward_command = WalkForward` -- `raw_state.forward_holdkey = Run` (because shift not held) -- `raw_state.forward_speed = 1.0` (so flag 0x10 is CLEAR) - -When the local player **releases W** (stops walking forward): -- `raw_state.forward_command = Ready (0x41000003)` — the Ready - default → **flag 0x04 is CLEARED** -- `raw_state.forward_speed = 1.0` → flag 0x10 CLEARED -- (HoldKey may still be Run from the toggle, so flag 0x08 may be set) - -So a STOP is **the absence of forward_command on the wire**, plus -the absence of forward_speed. It's encoded as "both flag bits clear, -implicit defaults Ready/1.0." - ---- - -## 17. SendDoMovementEvent — slash-command only - -**Function:** `ACCmdInterp::SendDoMovementEvent` -**Address:** `0x0058b230` -**Lines:** 405442–405455. - -```c -0058b230 int32_t SendDoMovementEvent(this, motion, speed, holdKey) { - return CM_Movement::Event_DoMovementCommand(motion, speed, holdKey); -} -0058b250 int32_t SendStopMovementEvent(this, motion, holdKey) { - return CM_Movement::Event_StopMovementCommand(motion, holdKey); -} -``` - -These are the **single-action** outbound messages used by slash -commands and macros (e.g. `/say`, `/use`). The cdb live trace from -2026-05-01 confirmed `SendDoMovementEvent` is NOT in the WASD path — -WASD always goes through `SendMovementEvent` → MoveToState -(§16). The DoMovement / StopMovement events are for one-shot motion -commands only. - ---- - -## Answers to the critical questions - -### Q: When the local actor stops (releases W), what UM does retail SEND outbound? - -**A retail-format 0xF61C MoveToState packet.** The packet's -RawMotionState has `forward_command = Ready (0x41000003)` and -`forward_speed = 1.0`. Both flag bits 0x04 and 0x10 in the -RawMotionState's flag word are CLEARED. HoldKey may remain set. - -**There is no separate "stop motion" packet on the WASD path.** The -release of W simply produces another full MoveToState whose raw -state shows Ready+1.0. ACE's relay then re-emits this as a 0xF74C -UpdateMotion to nearby observers, with the InterpretedMotionState's -flag 0x02 cleared (no forward_command field). - -### Q: When observer receives that UM, what does CMotionInterp::DoMotion do? - -The observer's `MovementManager::unpack_movement` reads movement_type=0 -(RawCommand), then `InterpretedMotionState::UnPack` runs (§5). With -the wire's flag 0x02 clear, **forward_command defaults to Ready**. -Flag 0x04 clear → forward_speed defaults to 1.0. Flag 0x08 clear → -sidestep_command = 0. Flag 0x20 clear → turn_command = 0. - -Then `MovementManager::move_to_interpreted_state` → -`CMotionInterp::move_to_interpreted_state` (§7) runs: -1. `InterpretedMotionState::copy_movement_from` bulk-copies the - defaults into the body's interpreted state. -2. `apply_current_movement` → `apply_interpreted_movement` (§8) fires - `DoInterpretedMotion(Ready, ...)` for forward, - `StopInterpretedMotion(SideStepRight)` for sidestep, and - `StopInterpretedMotion(TurnRight)` for turn. -3. `get_state_velocity` returns (0, 0, 0) because forward_command is - Ready (matches neither WalkForward nor RunForward). -4. `set_local_velocity(0, 0, 0)` — body stops moving. - -**Note `DoMotion` itself is NOT called here.** The wire-driven -relay path uses `move_to_interpreted_state`, not `DoMotion`. -`DoMotion` is the LOCAL command path (e.g. `MoveToManager`, -slash commands, animation hooks). - -### Q: Does retail observer also have a "stop signal" path via UpdatePosition (separate from UM)? - -**No, not for the stop semantics.** UpdatePosition (0xF748) is for -position teleports / heartbeat re-syncs and goes through -`SmartBox::UnpackPositionEvent` (357185, line 357181 case 0xf748). -It does NOT touch InterpretedMotionState. Position can move the body -in space, but the locomotion command (Walk/Run/Ready) is purely -UM-driven. - -That said, if a player is moving and stops, the next AutonomousPosition -(0xF749/0xF75A) heartbeat from the server will keep the position -matching, but it's the UM that delivers the Ready transition. - -The local prediction layer (`SmartBox::QueueBlobForObject`) holds an -inbound UM if the entity is not yet known — but once known, every -inbound 0xF74C is processed by UnPack → move_to_interpreted_state in -order. - -### Q: How does sign-flipped backward (WalkForward + ForwardSpeed = -1) get processed? - -**Receiver side (UM observer / DoMotion local):** - -1. `InterpretedMotionState::UnPack` reads - `forward_command = 0x45000005 (WalkForward)` and - `forward_speed = -1.0f` verbatim from the wire. -2. `move_to_interpreted_state` → `copy_movement_from` writes those - into InterpretedState unchanged. -3. `apply_interpreted_movement` calls - `DoInterpretedMotion(WalkForward, params{speed=-1.0})`. -4. `DoInterpretedMotion` → `CPhysicsObj::DoInterpretedMotion` → - `get_state_velocity`: - - WalkForward branch hits, `velocity.Y = 3.12 × -1.0 = -3.12 m/s`. -5. `set_local_velocity` pushes a NEGATIVE Y velocity → body translates - backward in body-local frame. - -Critically: `adjust_motion` is **NOT** called on the receive path for -sign-flipped backward. It was already called at the SENDER (typically -the originating client's local `DoMotion`). Once the wire has -`WalkForward + speed=-1.0`, that's the canonical form. ApplyMotion -and copy_movement_from simply copy it. - -**On the SEND side**, the local `DoMotion(WalkBackward, +1.0)`: -1. `adjust_motion` flips: motion → WalkForward, speed → -0.65 × - BackwardsFactor. -2. If HoldKey == Run, `apply_run_to_command` checks `speed > 0` — - FALSE — so the motion stays WalkForward (no promotion to - RunForward), but speed gets multiplied by speedMod (runRate). -3. Final: motion=WalkForward, speed = -0.65 × runRate. -4. `RawMotionState::ApplyMotion` writes that back (when ModifyRawState - bit set), so the next outbound MoveToState carries - `forward_command=WalkForward, forward_speed=-0.65×runRate`. - -### Q: What's the difference between apply_run_to_command and DoInterpretedMotion? - -| Aspect | apply_run_to_command (305062) | DoInterpretedMotion (305575) | -|---|---|---| -| Purpose | **Modifier**: rewrite motion+speed for HoldKey.Run | **Action**: fire physics velocity push + queue + state update | -| Inputs | `motion*, speed*` (in/out), uses my_run_rate | `motion, MovementParameters` | -| Side effects | NONE (pure rewrite via ref params) | Updates InterpretedState (if flag set), enqueues motion, calls `CPhysicsObj::DoInterpretedMotion` (which calls `set_local_velocity`) | -| Promotes WalkForward → RunForward | YES (when speed > 0) | NO | -| Applies speedMod | YES (multiplies speed by runRate) | NO | -| Called from | `adjust_motion` (305388) when holdKey == Run | `DoMotion` (306211), `move_to_interpreted_state` (305983), `apply_interpreted_movement` (305744) | - -`apply_run_to_command` is a **command-rewriter** that runs once at -input time. `DoInterpretedMotion` is the **executor** that fires -many times per UM (once per forward/sidestep/turn axis). - ---- - -## Cross-reference with ACE's MotionInterp - -ACE's `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` -mirrors retail with high fidelity: - -| ACE method | Retail equivalent | Lines (retail) | Differences | -|---|---|---|---| -| `DoMotion` (l.112) | `CMotionInterp::DoMotion` | 306159 | Identical structure. Uses `ModifyRawState` flag from `MovementParameters`. | -| `DoInterpretedMotion` (l.51) | same name | 305575 | Identical. | -| `StopMotion` (l.367) | same | 305674 | Identical. | -| `StopInterpretedMotion` (l.329) | same | 305635 | Identical. | -| `StopCompletely` (l.301) | same | 305208 | Identical including `forward_command=Ready, speed=1, side=0, turn=0`. | -| `adjust_motion` (l.394) | same | 305343 | Identical. ACE uses `BackwardsFactor=-1` (?? check) — retail `-0.65`. ACE source shows `speed *= -BackwardsFactor` with a separate constant declaration; need to confirm value. | -| `apply_run_to_command` (l.525) | same | 305062 | Identical mappings. ACE `MaxSidestepAnimRate=3.0f` matches retail. | -| `contact_allows_move` (l.584) | same | 305471 | Identical. | -| `get_state_velocity` (l.678) | same | 305160 | Identical. ACE uses `WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25` matching retail. | -| `apply_interpreted_movement` (l.440) | same | 305713 | Identical re-application of forward → sidestep → turn. | -| `move_to_interpreted_state` (l.789) | same | 305936 | Identical. ACE's stamp comparison logic matches the 15-bit wrap. | - -ACE's port is faithful. The only deviations seen so far are in the -`apply_raw_movement` path which ACE uses for autonomous (player-self) -echoes, but this isn't on the L.3 critical path. - ---- - -## Cross-reference with acdream - -### `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2591) - -**This is acdream's UM handler. It DIVERGES from retail in important -structural ways.** - -acdream's path: -1. Pull `update.MotionState.Stance`, `ForwardCommand`, `ForwardSpeed` - etc. directly from the parsed wire packet. -2. For non-self entities, **directly mutate** - `remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion` - and `.ForwardSpeed = speedMod` (l.2860, 2868). This is - acdream's equivalent of `copy_movement_from` — but only for - ForwardCommand+ForwardSpeed, NOT a full bulk copy. -3. SideStep + Turn axes are handled separately via - `remoteMot.Motion.DoInterpretedMotion(...)` / - `StopInterpretedMotion(...)` (l.3073, 3079, 3109, 3121). -4. The animation sequencer is driven by a separately computed - `animCycle` with its own priority logic - (Forward → Sidestep → Turn → Ready) at l.2918–2952. - -### Divergences from retail: - -1. **No bulk `copy_movement_from`.** acdream only copies - ForwardCommand+ForwardSpeed when those wire bits change. Retail - always copies all 7 fields. Consequence: on a stop UM (no command - bits set), acdream's parser produces command=null and speed=null; - the assignment at l.2860 only fires when ForwardCommand changed. - **It's possible for InterpretedState fields to retain stale - values across stop UMs** if the parser logic doesn't normalize - absence to "Ready/1.0." (Need to audit - `WorldSession.EntityMotionUpdate.MotionState` — does it default - on absence the same way `InterpretedMotionState::UnPack` does? - Per CLAUDE.md memory entry on Phase L.X, the wire parser had - bits wrong before — flag mapping is now correct.) - -2. **No per-axis `apply_interpreted_movement` re-fire.** Retail's - `apply_interpreted_movement` re-runs `DoInterpretedMotion` for - each axis on every state change, which calls - `CPhysicsObj::DoInterpretedMotion` and ultimately - `get_state_velocity` → `set_local_velocity`. acdream's port skips - this re-fire — it relies on per-tick logic in `TickAnimations` to - pick up the InterpretedState change next frame. This is the - "staircase" issue noted in CLAUDE.md. - -3. **MotionInterpreter.cs `DoMotion` does not match retail.** - acdream's `MotionInterpreter.DoMotion` (l.381–395) just records - RawState and forwards to `DoInterpretedMotion(motion, speed, - modifyInterpretedState:true)`. Retail's `DoMotion` calls - `adjust_motion` first, then `DoInterpretedMotion`, then - conditionally `RawMotionState::ApplyMotion`. The acdream version - skips the `adjust_motion` call — meaning a local - `DoMotion(WalkBackward, +1.0)` would NOT get sign-flipped to - `WalkForward + -0.65`. (For the L.3 receiver path this doesn't - matter because the wire already carries the post-adjust form; - for the local-player command path it does matter and is a separate - bug.) - -4. **`get_state_velocity` (MotionInterpreter.cs l.587)** is faithful - to retail except it adds an Option-B path that reads from - `GetCycleVelocity` (the sequencer's MotionData.Velocity) when - available — overriding the hardcoded `RunAnimSpeed=4.0` constant - with the dat-baked velocity. This is a deliberate enhancement - for non-humanoid creatures (different MotionData scales) and is - noted in the comment block. It's safe because the max-speed clamp - below still uses `RunAnimSpeed × runRate`. - -5. **`StopInterpretedMotion` (MotionInterpreter.cs l.460)** does NOT - re-run `apply_interpreted_movement`. It only edits InterpretedState - then calls `apply_current_movement(false, false)` — which itself - doesn't re-fire per-axis like retail does. This matches the - retail single-stop semantics, but combined with #2 above it means - a single sidestep-clear UM doesn't immediately push zero X velocity - to the body. - -### Files to compare side-by-side during the L.3 port: - -- `src/AcDream.Core/Physics/MotionInterpreter.cs` — the executor -- `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` — the - receiver glue -- `src/AcDream.Core/Net/WorldSession.cs` (search for - `EntityMotionUpdate`) — the wire parser -- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` - — ACE's mirror -- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:305062–306268 - — retail source of truth. diff --git a/docs/research/2026-05-04-l3-port/03-up-routing.md b/docs/research/2026-05-04-l3-port/03-up-routing.md deleted file mode 100644 index 9fb70288..00000000 --- a/docs/research/2026-05-04-l3-port/03-up-routing.md +++ /dev/null @@ -1,585 +0,0 @@ -# UpdatePosition (0xF748) Routing Pipeline — Retail Pseudo-C Extract - -**Date:** 2026-05-04 -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C) -**Cross-reference:** `docs/research/named-retail/acclient.h` (verbatim retail headers) - -This document extracts the complete UpdatePosition routing tree from the -retail acclient — from the inbound F748 dispatcher down to the body's -position. Every branch is cited verbatim with the originating retail -line number. Cross-checked against acdream's -`OnLivePositionUpdated` in `src/AcDream.App/Rendering/GameWindow.cs:3425` -and `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`. - ---- - -## 0. Pipeline overview (Mermaid) - -``` -Network blob (NetBlob, opcode 0xF748) - │ - ▼ -ACSmartBox::DispatchSmartBoxEvent (357117) ─── case 0xF748 ───┐ - │ │ - ▼ │ -SmartBox::UnpackPositionEvent (93055) ◄── reads PositionPack ─┘ - │ - │ PositionPack = { Position pos, Vec3 velocity, uint32 placement_id, - │ uint32 has_contact, uint16 instance_timestamp, - │ uint16 position_timestamp, - │ uint16 teleport_timestamp, - │ uint16 force_position_timestamp } - │ - ▼ if instance_timestamp == obj.update_times[INSTANCE_TS] -SmartBox::HandleReceivedPosition (92896) - args: arg2 = target CPhysicsObj - arg3 = Position* (objcell_id + Frame{origin, rotation}) - arg4 = placement_id (uint32) - arg5 = has_contact (int32; 0 = airborne, !0 = grounded) - arg6 = velocity Vec3* - arg7 = position_timestamp (uint16) ← POSITION_TS - arg8 = teleport_timestamp (uint16) ← TELEPORT_TS / move-seq - arg9 = force_position_timestamp (uint16) ← FORCE_POSITION_TS - │ - ├─[ if arg2 == player AND newer_event(player, FORCE_POSITION_TS, arg9) ] - │ ► SmartBox::BlipPlayer (92928) — server forced our pos - │ ► return - │ - ├─[ if newer_event(arg2, POSITION_TS, arg7) == 0 ] - │ ► return — stale position update - │ - ├─[ if arg2 != player ] - │ ► CPhysicsObj::MoveOrTeleport(arg2, &recvPos, arg8, arg5, arg6) (92997) - │ │ - │ └── (see Section 3 below) - │ ► if MoveOrTeleport returned 1: CPhysicsObj::ConstrainTo (93007) - │ ► return - │ - └─[ if arg2 == player ] - ├─[ if newer_event(player, TELEPORT_TS, arg8) ] - │ ► SmartBox::TeleportPlayer (93015) - │ ► CPhysicsObj::ConstrainTo (93024) - │ ► CPhysicsObj::set_velocity(player, 0) (93029) - │ ► return - │ - └─[ else ] - ► CPhysicsObj::ConstrainTo(player, …) (93041) - ► if cmdinterp.UsePositionFromServer && arg5 != 0: - CPhysicsObj::InterpolateTo(arg2, &recvPos, …) (93049) -``` - -Key insight: the `arg2 != player` branch (remotes) is the one that fires -into `MoveOrTeleport`. That's the only place the routing decision tree -between hard-snap, slide-snap, and InterpolateTo lives. The -`arg2 == player` branches (server-corrected local) do their own thing -(BlipPlayer / TeleportPlayer / ConstrainTo + InterpolateTo). - ---- - -## 1. The packet entry — ACSmartBox::DispatchSmartBoxEvent - -**File line:** 357117 — `0x005595d0` - -Verbatim retail (excerpt of `case 0xF748`): - -```c -357181 case 0xf748: -357182 { -357183 ebp_1 = *(uint32_t*)(buf_ + 4); // object guid -357184 arg2 = &buf_[8]; // payload start -357185 result = SmartBox::UnpackPositionEvent(this, ebp_1, &arg2, bufSize_); -357187 if (result != NETBLOB_QUEUED) -357188 return result; -357190 SmartBox::QueueBlobForObject(this, ebp_1, ebx); // not yet known -357191 return result; -357192 } -``` - -**Notes** -- The opcode dispatch is a simple `switch (ecx)`; F748 is *only* the - generic UpdatePosition. F619 = "MoveObject" (player's own moves) - routes through the **same** `UnpackPositionEvent`, then falls through - into `SetObjectMovement` if the unpack succeeded — see lines 357138– - 357158. F74C is the server-controlled-move variant that drops in via - a different sequence-stamp check. F748 is the pure-position event. -- `QueueBlobForObject` parks the blob if the target object isn't yet - known to the client, so the position is replayed after CreateObject. - ---- - -## 2. UnpackPositionEvent — gating on `instance_timestamp` - -**File line:** 93055 — `0x004542c0` - -```c -93055 enum NetBlobProcessedStatus -93055 SmartBox::UnpackPositionEvent(this, arg2 /*guid*/, arg3 /*payload**/, arg4 /*size*/) -93055 { -93059 PositionPack::PositionPack(&var_68); -93060 PositionPack::UnPack(&var_68, arg3, arg4); -93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2); -93063 if (eax_1 != 0) -93063 { -93065 ecx_4 = eax_1->update_times[8]; // INSTANCE_TS -93081 if (newer-by-rolling-uint16(esi /*var_64*/, ecx_4) == 0) // EQUAL -93081 { -93083 if (ecx_4 != esi) -93084 return 2; // NETBLOB_LOGGED_OUT -93092 SmartBox::HandleReceivedPosition( -93092 this, eax_1, &recvPos, placement_id, has_contact, -93092 &velocity, position_ts, teleport_ts, force_position_ts); -93093 return 1; // NETBLOB_PROCESSED_OK -93081 } -93095 } -93097 return 4; // NETBLOB_QUEUED -93055 } -``` - -**Notes** -- `update_times[8]` is `INSTANCE_TS` (`enum PhysicsTimeStamp::INSTANCE_TS = 0x8`, - `acclient.h:6094`). The full array is `unsigned __int16 update_times[9]` - (`acclient.h:30738`). -- The instance check is "must equal the recorded instance stamp" - (after rolling-uint16 normalization). Mismatch returns NETBLOB_LOGGED_OUT; - unknown object returns NETBLOB_QUEUED. - -### PositionPack contents (line 284585) - -```c -284589 ebx = first byte of payload // flags byte -284591 Position::UnPackOrigin(&this->position, …) // 12 bytes float3 + uint cell -284593 if (ebx & 8) == 0: read qw (else 0) -284601 if (ebx & 0x10) == 0: read qx (else 0) -284609 if (ebx & 0x20) == 0: read qy (else 0) -284617 if (ebx & 0x40) == 0: read qz (else 0) -284625 Frame::cache(&position.frame); // recompute cached matrix -284628 if (ebx & 1) != 0: read velocity (12 bytes) -284646 if (ebx & 2) != 0: read placement_id (4 bytes) -284654 has_contact = (ebx >> 2) & 1; -284655 read uint16 instance_timestamp; -284660 read uint16 position_timestamp; // POSITION_TS -284664 read uint16 teleport_timestamp; // used as move-seq for arg8 below -284667 read uint16 force_position_timestamp; // FORCE_POSITION_TS -``` - -Observation: the wire field called "teleport_timestamp" is reused as -the **move-seq** that gets passed as `arg8 = arg3` into -`MoveOrTeleport`. It indexes `update_times[TELEPORT_TS=4]` in the -`newer_event(this, TELEPORT_TS, arg3)` check inside MoveOrTeleport -(284325). One stamp, two purposes — for remotes it acts as a generic -"move sequence"; for the local player it triggers the teleport branch -in HandleReceivedPosition (93013). - ---- - -## 3. CPhysicsObj::MoveOrTeleport — the ROUTER - -**File line:** 284304 — `0x00516330` - -This is the function L.3 has to port faithfully. Verbatim: - -```c -284304 int32_t __thiscall CPhysicsObj::MoveOrTeleport( -284304 class CPhysicsObj* this, -284304 class Position* arg2, // received position (objcell_id + Frame) -284304 uint16_t arg3, // move-seq (TELEPORT_TS slot value) -284304 int32_t arg4, // has_contact (0 = airborne, !0 = grounded) -284304 class AC1Legacy::Vector3 const* arg5) // velocity vector -284306 { -284307 class CPhysicsObj* this_1 = this; -284308 this = this_1->update_times[4]; // current TELEPORT_TS -284311 // rolling-uint16 compare: arg3 vs current update_times[4] -284321 if ( delta-from-rolling-uint16(this, arg3) == 0 ) // SAME-OR-NEWER -284321 { -284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3); -284327 // └── writes update_times[4]=arg3 if newer ──┘ -284327 if ( eax_8 != 0 || this_1->cell == 0 ) -284327 { - // ───────── BRANCH A: HARD TELEPORT ───────── -284329 int32_t var_70_3 = 1; -284330 CPhysicsObj::teleport_hook(this_1, edx_2); -284332 SetPositionStruct sps; -284332 SetPositionStruct::SetPositionStruct(&sps); -284333 SetPositionStruct::SetPosition(&sps, arg2); -284334 SetPositionStruct::SetFlags(&sps, 0x1012); // ← TELEPORT FLAGS -284335 CPhysicsObj::SetPosition(this_1, &sps); -284336 SetPositionStruct::~SetPositionStruct(&sps); -284337 return 1; - } - -284340 if ( arg4 != 0 ) // GROUNDED? -284340 { - // arg4 == has_contact != 0 -284342 long double playerDist = this_1->player_distance; // float -284343 long double thresh96 = 96.0f; // ★ -284347 if ( playerDist >= thresh96 ) // ★ -284347 { - // ───────── BRANCH B: WITHIN BUBBLE → INTERPOLATE ───────── -284351 CPhysicsObj::InterpolateTo(this_1, arg2, -284351 CPhysicsObj::IsMovingTo(this_1)); -284352 return 1; -284347 } - // ───────── BRANCH C: BEYOND BUBBLE → SLIDE-SNAP ───────── -284355 class PositionManager* position_manager = this_1->position_manager; -284357 if ( position_manager != 0 ) -284358 PositionManager::StopInterpolating(position_manager); - -284360 CPhysicsObj::SetPositionSimple(this_1, arg2, /*slide=*/1); -284361 return 1; - } -284362 } -284365 return 0; // STALE — ignore -284366 } -``` - -★ — the float comparison on lines 284343–284349 is x87 nonsense in the -decomp output but the semantic is straightforward. The `if (!p)` branch -is taken when `playerDist < 96f`. (Inverted — the literal pseudo-C -reads "if not (PF set after compare)" which means "comparison was -ordered and not (less or equal)". Cross-checked against ACE -`PhysicsObj.cs::MoveOrTeleport` which reads -`if (player_distance >= MaxObjectTrackingDistance) InterpolateTo(...) else SetPositionSimple(...)` -— SO the labelling above (Branch B = within bubble = InterpolateTo) is -correct as written. Verify on port via cdb if uncertain.) - -### The router's three exits - -| Branch | Condition | Action | -|--------|-----------|--------| -| **A — Hard Teleport** | `newer_event(TELEPORT_TS, arg3) != 0` (move-seq advanced) **OR** `cell == 0` (object isn't placed yet) | `SetPosition` with flags **0x1012** — teleport-style placement (full sphere validation, `change_cell`, `AddShadowObject`). Position immediately becomes the received position. | -| **B — Interpolate** | grounded (`has_contact != 0`) AND **within view bubble** (`player_distance < 96 m`) | `InterpolateTo(recvPos, IsMovingTo)` — **enqueues a waypoint**, body's m_position is NOT changed yet. | -| **C — Slide-snap** | grounded AND **beyond view bubble** (`player_distance >= 96 m`) | `StopInterpolating` (drop queue) + `SetPositionSimple(recvPos, slide=1)` — body snaps to received position, but with `0x1012` flag *omitted* (so this is a softer placement than teleport — see Section 5). | - -Air branch (`has_contact == 0`): the function falls through to -`return 0`. **This is the "AIRBORNE NO-OP"** that acdream's -`OnLivePositionUpdated` mirrors at line 3570. The body keeps integrating -gravity locally; received position is discarded. - -### Distance constants - -- **MAX_PHYSICS_DISTANCE = 96 f** (line 284343) — the in-bubble vs - out-of-bubble threshold inside MoveOrTeleport. **This is the - hard-coded float in the retail binary**; no symbol name in the PDB. -- **CREATURE_OUTSIDE_BLIP_DISTANCE = 100 f** — used elsewhere - (`CMonsterMode::IsBlippable` and similar) to decide visibility blips. - NOT used by MoveOrTeleport. -- **CREATURE_INSIDE_BLIP_DISTANCE = 20 f** — blip threshold for indoor - cells. Also outside MoveOrTeleport. - -Only the 96 f figure is on the routing path. The 100/20 figures are -display-only and live in the BlipPlayer / view-cone code. - ---- - -## 4. CPhysicsObj::InterpolateTo — the QUEUE side - -**File line:** 278344 — `0x005104f0` - -```c -278344 void __thiscall CPhysicsObj::InterpolateTo( -278344 class CPhysicsObj* this, -278344 class Position const* arg2, -278344 int32_t arg3 /* IsMovingTo? — the object is following an MTP route */) -278346 { -278347 CPhysicsObj::MakePositionManager(this); -278348 PositionManager::InterpolateTo(this->position_manager, arg2, arg3); -278349 } -``` - -This is two lines: ensure a PositionManager exists, then forward. -`PositionManager::InterpolateTo` (line 352136) creates an -`InterpolationManager` lazily and forwards again to -`InterpolationManager::InterpolateTo` (line 352892). - -### InterpolationManager::InterpolateTo — what actually queues - -```c -352892 void InterpolationManager::InterpolateTo(this, arg2 /*Position**/, arg3 /*isMovingTo*/) -352892 { -352899 tail_ = this->position_queue.tail_; -352902 // Compare new waypoint to the last queued one (or current m_position) -352908 dist = Position::distance( queueTailOrCurrentPos, arg2 ); -352911 autonomyBlipDist = CPhysicsObj::GetAutonomyBlipDistance(physobj); // float -352918 if ( dist > autonomyBlipDist ) // ★ FAR -352918 { - // ── Far: enqueue a new InterpolationNode ── -352920 node = operator new(0x60); -352926 edi_1 = InterpolationNode::InterpolationNode(node); -352928 edi_1->kind = 1; // POSITION node -352929 edi_1->objcell_id = arg2->objcell_id; -352930 Frame::operator=(&edi_1->frame, &arg2->frame); -352932 if ( this->keep_heading ) -352934 CPhysicsObj::get_heading(physobj); // overwrite heading w/ current -352935 Frame::set_heading(&edi_1->frame, currentHeading); - node_fail_counter = 4; // 4 retry slots -352942 // append to tail (or set head+tail if empty) -352945 return; -352918 } - - // ── Near: dist <= AutonomyBlipDistance ── -352956 dist2 = Position::distance(&physobj->m_position, arg2); -352962 if ( dist2 <= 0.05 f ) // 5 cm -352962 { -352964 if ( arg3 == 0 ) // not following MTP route -352968 CPhysicsObj::set_heading(physobj, frame.get_heading(), 1); -352973 InterpolationManager::StopInterpolating(this); // wipe queue -352974 return; -352962 } - - // ── Mid-distance: collapse adjacent waypoints into ours ── -352977 while ( queue.tail kind==1 AND Position::distance(tail, arg2) <= 0.05f ) -352977 remove tail; -352986 // …then enqueue our waypoint at the end (loop @ 353004 follows) -352892 } -``` - -**Critical answer to the cross-question — does the body's CURRENT -position change immediately on InterpolateTo?** - -**No.** InterpolateTo only manipulates `position_queue`. The body's -`m_position` is advanced by `InterpolationManager::adjust_offset` / -`UpdateInterpolation`, which runs from -`CPhysicsObj::UpdatePhysicsInternal` each tick. The queue is a -sequence of waypoints; the body chases them at the natural movement -speed driven by `MoveToManager` and `RawMotionState`. - -There are two clear early-exits: -1. If the new waypoint is within **5 cm** of the body's current - position (`0.05 f` literal, line 352957), `StopInterpolating` - wipes the queue and the function returns. No queue change. -2. If `keep_heading` is set, the queued waypoint inherits the - physobj's CURRENT heading (line 352934) — meaning the queued frame's - rotation is overwritten before insertion. This is how retail - prevents a "snap to face north on UP" on creatures that are mid- - strafe. - ---- - -## 5. CPhysicsObj::SetPosition / SetPositionSimple — the SNAP side - -### SetPositionSimple (line 284276 — `0x005162b0`) - -```c -284276 enum SetPositionError CPhysicsObj::SetPositionSimple( -284276 class CPhysicsObj* this, -284276 class Position const* arg2, -284276 int32_t arg3 /* slide flag */) -284278 { -284279 uint32_t flags = 0x1002; // base: place-collide+place-no-onwalkable? -284281 if ( arg3 != 0 ) -284282 flags = 0x1012; // + slide flag (0x10 = SCATTER?) -284284 SetPositionStruct sps; -284285 SetPositionStruct::SetPositionStruct(&sps); -284286 SetPositionStruct::SetPosition(&sps, arg2); -284287 SetPositionStruct::SetFlags(&sps, flags); -284288 result = CPhysicsObj::SetPosition(this, &sps); -284290 return result; -284291 } -``` - -Compare with the BRANCH-A teleport in MoveOrTeleport (284334) — it also -uses **0x1012**. So Branch C (slide-snap) and Branch A (teleport) -produce the **same** SetPositionStruct flag. The difference is purely -the conditional path that got us here: - -- Branch A: `arg3 != 0`, fires when teleport_timestamp advanced OR cell - was nil. Wraps with `teleport_hook(this_1, …)`. -- Branch C: fires when `arg3 == 0` (move-seq UNCHANGED) and we're - beyond the 96 m bubble. No teleport_hook, but **does** call - `StopInterpolating` first to drop any queued waypoints (since they're - no longer relevant — the visible position must immediately be the new - one). - -### SetPosition (line 284137 — `0x005160c0`) - -```c -284137 enum SetPositionError CPhysicsObj::SetPosition(this, SetPositionStruct* arg2) -284139 { -284141 eax = CTransition::makeTransition(); -284143 if ( eax == 0 ) return 1; // OK / fail-soft -284146 CTransition::init_object(eax, this, 0); - // …gather sphere(s) from this->part_array… -284190 CTransition::init_sphere(eax, num_sphere, sphere*, m_scale); -284191 result = CPhysicsObj::SetPositionInternal(this, arg2, eax); -284192 CTransition::cleanupTransition(eax); -284193 return result; -284137 } -``` - -The internals: `SetPositionInternal` is the place where the body's -`m_position` actually gets written, after running `CTransition` to -validate the move (collision, walkable-floor, change_cell, etc.). -**By the time MoveOrTeleport returns 1 on Branch A or C, the body's -m_position equals the received position.** This is in stark contrast -to Branch B (InterpolateTo), where m_position is unchanged. - -### SetPositionStruct flags reference - -The PDB doesn't expose individual flag-bit names, but cross-referenced -against ACE (`PhysicsObj.cs`/`SetPositionStruct.cs`): - -| Bit | ACE name | Used here? | -|-----|----------|------------| -| 0x0001 | `Placement` | yes (in 0x1012 / 0x1002) | -| 0x0002 | `Sliding` | sometimes | -| 0x0010 | `Slide` | yes (the +0x10 in SetPositionSimple's `arg3 != 0`) | -| 0x1000 | `SendPositionEvent` | yes (always set in MoveOrTeleport branches) | - -So **0x1012 = Slide + Placement + SendPositionEvent**. 0x1002 (the -`arg3 == 0` SetPositionSimple branch, used for non-slide simple -placement, NOT MoveOrTeleport) is just `Placement | SendPositionEvent`. - ---- - -## 6. CPhysicsObj::IsMovingTo (line 276430 — `0x0050eb10`) - -```c -276430 int32_t CPhysicsObj::IsMovingTo(class CPhysicsObj const* this) -276432 { -276433 class MovementManager* mm = this->movement_manager; -276435 if ( mm != 0 && MovementManager::IsMovingTo(mm) != 0 ) -276436 return 1; -276438 return 0; -276430 } -``` - -Tells the caller whether the object is currently following a -goal-position via the `MoveToManager`'s scripted-motion machine -(MoveToObject, MoveToPosition, "go to the door" type orders). -This is **not** the same as "is moving" generally — a creature -running a movement style (running cycle) but with no fixed -destination returns false here. - -This is the third arg passed into InterpolateTo (line 284351). When -`true`, `InterpolationManager::InterpolateTo`'s near-distance branch -**skips** the `set_heading` correction (line 352964) — the rationale -being that the MoveToManager already handles heading. - ---- - -## 7. Position::distance (line 438258 — `0x005a94b0`) - -```c -438258 AC1Legacy::Vector3* Position::distance( -438258 class Position const* this, -438258 class Position const* arg2) -438260 { -438262 result = Position::get_offset(this, &__return, arg2); -438266 return result; // …but the function name is misleading: - // it returns a Vector3 by value via __return -438258 } -``` - -`get_offset` does the cross-cell offset math (objcell_id-aware) and -fills a Vector3 with the world-space delta. `distance` then uses this -as a Vector3 (its caller calls `.x`/`.y`/`.z` and dot-products), or the -result is cast to a `float` length elsewhere. (Yes, the function name -is wrong — Turbine's joke.) - ---- - -## 8. Move-seq vs teleport-seq logic — the EXACT semantics - -Combining the dispatcher, UnpackPositionEvent, HandleReceivedPosition, -and MoveOrTeleport: - -| Stamp | Wire field | `update_times` slot | Meaning | -|-------|------------|---------------------|---------| -| `instance_timestamp` | uint16, 5th in PositionPack | `update_times[INSTANCE_TS=8]` | Object generation. UnpackPositionEvent rejects unless equal. | -| `position_timestamp` | uint16, 6th | `update_times[POSITION_TS=0]` | Generic "version" of *this* UP. HandleReceivedPosition drops if not newer. | -| `teleport_timestamp` | uint16, 7th | `update_times[TELEPORT_TS=4]` | **Doubles as move-seq** for remotes. MoveOrTeleport hard-snaps if newer than recorded. For local player → triggers SmartBox::TeleportPlayer. | -| `force_position_timestamp` | uint16, 8th | `update_times[FORCE_POSITION_TS=6]` | Server-forced relocation of OUR character. Triggers BlipPlayer (camera fixup, etc.) when newer. | - -The decision: **teleport_timestamp advanced** ⇒ hard-snap (Branch A). -**teleport_timestamp same** ⇒ soft branches (B/C). The 96 m bubble -selects B vs C only on the soft path. - -In wire terms: -- A normal "I'm running, server broadcasts my new position" UP has - the **same** teleport_ts as last time, so → InterpolateTo (Branch B). -- A "you got teleported by a portal / GM `@teleto` / death respawn" - UP advances teleport_ts by 1, so → SetPosition w/ teleport flags - (Branch A). - -### How this maps to acdream today - -`OnLivePositionUpdated` does NOT currently look at the -teleport_timestamp. The L.3 environment-variable port (lines 3508–3625 -of GameWindow.cs) already mirrors the air-no-op (line 3570) and the -96 m bubble (line 3606), but the **teleport_timestamp gate is -missing** — Branch A is never taken explicitly. Teleports today rely -on the WorldSession's own teleport pathway, which short-circuits the -UP routing. The L.3 follow-up should: -1. Plumb `update.TeleportTimestamp` from the WorldSession message - parser into `OnLivePositionUpdated`. -2. On UP receipt, compare against `rmState.TeleportTimestamp` and on - advance: clear queue, hard-snap body, run `teleport_hook`-equivalent. - ---- - -## 9. Orientation handling - -A clean answer to the cross-question: - -**Orientation is NOT queued separately.** It rides with the Position -struct (which carries `Frame { Vec3 origin; Quat (qw, qx, qy, qz) }`). -What happens to orientation depends on the branch: - -| Branch | Position behavior | Orientation behavior | -|--------|------------------|---------------------| -| **A — Teleport** | hard-snapped to recvPos | hard-snapped (Frame.cache rebuilds matrix in SetPositionInternal) | -| **B — InterpolateTo** | queued | **queued in the same Frame**. If `keep_heading` is set on the InterpolationManager, the queued Frame's rotation is **overwritten with the physobj's current heading** (line 352935). Otherwise, the body slerps toward the queued rotation as it walks. | -| **C — SetPositionSimple slide** | hard-snapped | hard-snapped (same Frame.cache path) | -| **AIRBORNE no-op** | unchanged | unchanged | - -acdream's current implementation in the env-var path **always -hard-snaps orientation immediately on UP receipt** (line 3516, -`rmState.Body.Orientation = rot;`) — this is a deliberate divergence -from retail (the `keep_heading` path) to keep the visual heading -in lock-step with the queue start, avoiding a one-frame lag -between body position and facing. Document this divergence in the -L.3 commit message; it is a known trade-off, not a bug. - ---- - -## 10. Cross-check: acdream env-var path vs retail - -| Step | Retail (MoveOrTeleport) | acdream env-var path (OnLivePositionUpdated, ACDREAM_INTERP_MANAGER=1) | -|------|------------------------|-----------------------| -| 1. Air check | line 284340–284362: `arg4==0` falls through to `return 0` | line 3570: `if (!update.IsGrounded) return;` ✓ | -| 2. Teleport stamp gate | line 284325–284337: `newer_event(TELEPORT_TS, arg3) → SetPosition(0x1012)` | **MISSING** — no teleport stamp comparison | -| 3. 96 m bubble | line 284343–284349: `player_distance < 96f` → InterpolateTo | line 3606: `MaxPhysicsDistance = 96f` ✓ | -| 4. InterpolateTo (queue) | line 284351: `InterpolateTo(arg2, IsMovingTo)` — preserves heading via keep_heading | line 3623: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false)` ✓ (but always passes `isMovingTo:false` and pre-extracts yaw from quat — minor divergence) | -| 5. Slide-snap | line 284360: `StopInterpolating + SetPositionSimple(slide=1)` | line 3613: `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` ✓ | -| 6. Cell change | retail: `change_cell` runs inside SetPositionInternal — handles landblock crossing and AddShadowObject | acdream: `_physicsEngine.ShadowObjects.UpdatePosition(...)` already runs upstream at line 3463, before the routing. ✓ (slight ordering difference — retail does it inside the SetPosition flow) | - -**Recommended L.3 follow-ups (not part of this research note):** -1. Plumb teleport_timestamp end-to-end and add the missing Branch A - gate. -2. Pass `IsMovingTo` properly (currently hard-coded to false). -3. Decide whether to honor `keep_heading` (acdream-side flag on the - sequencer) or keep the always-snap divergence — depends on whether - visible heading lag during MoveTo is acceptable. - ---- - -## Appendix A — symbol map - -| Function | Address | Line in retail decomp | -|----------|---------|----------------------| -| `ACSmartBox::DispatchSmartBoxEvent` | `0x005595d0` | 357117 | -| `SmartBox::UnpackPositionEvent` | `0x004542c0` | 93055 | -| `SmartBox::HandleReceivedPosition` | `0x00453fd0` | 92896 | -| `PositionPack::UnPack` | `0x00516740` | 284585 | -| `CPhysicsObj::MoveOrTeleport` | `0x00516330` | 284304 | -| `CPhysicsObj::SetPositionSimple` | `0x005162b0` | 284276 | -| `CPhysicsObj::SetPosition` | `0x005160c0` | 284137 | -| `CPhysicsObj::InterpolateTo` | `0x005104f0` | 278344 | -| `CPhysicsObj::IsMovingTo` | `0x0050eb10` | 276430 | -| `CPhysicsObj::newer_event` | `0x00451b10` | 90712 | -| `PositionManager::InterpolateTo` | `0x005551f0` | 352136 | -| `InterpolationManager::InterpolateTo` | `0x00555b20` | 352892 | -| `Position::distance` | `0x005a94b0` | 438258 | -| `enum PhysicsTimeStamp` | — | acclient.h:6084 | -| `struct SetPositionStruct` | — | acclient.h:52398 | - diff --git a/docs/research/2026-05-04-l3-port/04-interp-manager.md b/docs/research/2026-05-04-l3-port/04-interp-manager.md deleted file mode 100644 index cbfacf3e..00000000 --- a/docs/research/2026-05-04-l3-port/04-interp-manager.md +++ /dev/null @@ -1,497 +0,0 @@ -# InterpolationManager — full retail port reference - -**Date:** 2026-05-04 -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp). -**Cross-check:** `src/AcDream.Core/Physics/InterpolationManager.cs` (current port), `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` (cdb-traced). - ---- - -## 1. Class layout (verbatim from `acclient.h`) - -```c -struct __cppobj LList : LListBase {}; - -struct __cppobj InterpolationManager -{ - LList position_queue; // 0x00 (head_, tail_) — singly-linked - CPhysicsObj *physics_obj; // 0x08 - int keep_heading; // 0x0C - unsigned int frame_counter; // 0x10 - float original_distance; // 0x14 - float progress_quantum; // 0x18 - int node_fail_counter; // 0x1C - Position blipto_position; // 0x20 ... 0x68 (size 0x68 total per `operator new(0x68)`) -}; -``` - -`InterpolationNode` (size 0x60, allocated via `operator new(0x60)` in `InterpolateTo`): - -| Offset | Field | Notes | -|---|---|---| -| 0x00 | `llist_next` | LListBase next-pointer | -| 0x04 | `type` | 1 = position waypoint, 2/3 = velocity-bearing nodes (rare). UseTime velocity-snap reads tail+0x50 / +0x54 / +0x58 (Vector3) for type 2/3. | -| 0x08 | vtable / sentinel | `0x79285c` written before `delete` | -| 0x0C | `objcell_id` | uint32 cell ID | -| 0x10 | `frame` (begin) | Position::frame, 0x44 bytes (qw,qx,qy,qz,Origin{x,y,z}, cache) | -| ... | (Position internals) | | -| 0x50..0x58 | velocity Vector3 | only meaningful for `type` 2/3 | - -**Queue is FIFO singly-linked.** `head_` is the node we walk *toward* first; `tail_` is the most-recent enqueue (the latest server-reported position). - ---- - -## 2. Constructor / Destroy / StopInterpolating - -### `InterpolationManager::InterpolationManager` @ `0x005558d0` - -```c -this->original_distance = 999999f; // sentinel — first window has no baseline -this->position_queue.head_ = nullptr; -this->position_queue.tail_ = nullptr; -this->frame_counter = 0; -this->progress_quantum = 0f; -this->node_fail_counter = 0; -// blipto_position initialised to identity Frame (qw=1, origin=0) -this->blipto_position.objcell_id = 0; -Frame::cache(&this->blipto_position.frame); -this->physics_obj = arg2; -``` - -### `InterpolationManager::Destroy` @ `0x00555af0` - -Free every node in `position_queue`: - -```c -while (head_ = this->position_queue.head_; head_ != 0) { - LListData *next = head_->llist_next; - this->position_queue.head_ = next; - if (next == 0) this->position_queue.tail_ = next; - head_->llist_next = 0; - *(uint32_t*)((char*)head_ + 8) = 0x79285c; // overwrite vtable - operator delete(head_); -} -``` - -### `InterpolationManager::StopInterpolating` @ `0x00555950` - -Same node-drain loop as `Destroy`, **then resets all stall state**: - -```c -this->frame_counter = 0; -this->progress_quantum = 0f; -this->node_fail_counter = 0; -this->original_distance = 999999f; -``` - -> The `999999f` sentinel matters: the first 5-frame window after a fresh Stop will compute `cumulative_progress = 999999 - currentDist`, which is huge and **passes** the stall check. This is retail's intended way of suppressing first-window false-positives. Our port replaces this with an explicit `_haveBaselineDistance` boolean — equivalent. - ---- - -## 3. `InterpolationManager::InterpolateTo` (AppendNode) @ `0x00555b20` - -Signature: `void InterpolateTo(InterpolationManager *this, Position const *arg2, int32_t arg3)` -- `arg2` = new server-authoritative target position. -- `arg3` = `keep_heading` flag (1 = preserve current physics heading instead of using the wire heading). - -### Branching: - -1. **Compute `dist`**: from either (a) the tail's stored position if tail exists AND `tail->type == 1`, otherwise (b) the physics object's current `m_position`. Then: - ```c - dist = Position::distance(reference, arg2); - blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj); - ``` - -2. **Far branch** — `dist > GetAutonomyBlipDistance()` (100m outdoor / 20m indoor for creatures): - - Allocate `InterpolationNode` (`new(0x60)` + `InterpolationNode::InterpolationNode`). - - `node->type = 1`; copy objcell_id; copy frame. - - If `keep_heading`, overwrite frame heading with `physics_obj->get_heading()`. - - **Append to tail** (the canonical AppendNode op). - - `this->node_fail_counter = 4;` ← **important**: forces an immediate blip-to-tail on the next `UseTime` (4 > 3 threshold). This is retail's "we're so far out of sync, just teleport" reflex. - - Return. - -3. **Near & already-very-close branch** — `dist <= blip` AND `Position::distance(physics_obj->m_position, arg2) <= 0.05` (`DESIRED_DISTANCE`): - - If `arg3 == 0`, set heading directly: `physics_obj->set_heading(arg2->frame.get_heading(), 1)`. - - `StopInterpolating(this);` - - Return. (No node enqueued — body is already where it needs to be.) - -4. **Near & not-yet-close branch** — `dist <= blip`: - - **Tail-prune duplicates**: while `tail->type == 1` AND `Position::distance(tail, arg2) <= 0.05`, `LListBase::RemoveTail` and delete it. - - **Cap at 20**: walk the list counting nodes; if count >= `0x14` (20), drop the head. (Loop `00555c73`–`00555cb1`.) - - Set `this->keep_heading = arg3;` - - Allocate, fill (type=1, copy objcell+frame, optionally override heading), append to tail. - -### Answer: **AppendNode behavior** - -There is no separate `AppendNode` symbol — the logic is inlined in `InterpolateTo`. The retail behaviors that matter for our port: - -- **Duplicate-prune is a tail-walking loop**, not a single tail-comparison. (Multiple stale tail entries within 0.05 m of the new target all collapse.) -- **Cap eviction is at the HEAD** when reaching 20 entries. -- **Far enqueue forces `node_fail_counter = 4`** to trigger immediate tail-blip next tick. - -Our current port does duplicate-prune against only the last entry (`_queue.Last`), drops head on cap — **functionally equivalent** because the tail-walk converges to the same result given a single new arg2. **It does NOT replicate the `node_fail_counter = 4` "force blip" on far enqueue** — see § 7 gap analysis. - ---- - -## 4. `InterpolationManager::adjust_offset` @ `0x00555d30` - -Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)` -- `arg2` = output Frame (already-zeroed identity Frame from caller `CPhysicsObj::UpdatePartsInternal` @ `0x00512c3c`). -- `arg3` = frame `dt` in seconds (double). - -### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return. - -The last meaningful action (line 353253) is: - -```c -00555f10 Frame::operator=(arg2, &__return); -``` - -where `__return` is a Frame whose `m_fOrigin` was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with: - -```c -00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/); -``` - -**So `adjust_offset` writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position.** Our acdream port returns a `Vector3` delta, which the caller adds to the body — equivalent semantics. - -### Step-by-step retail flow: - -```c -LListData *head_ = this->position_queue.head_; -if (head_ == 0) return; // empty queue → no-op - -CPhysicsObj *po = this->physics_obj; -if (po == 0) return; - -// ---- GATE on transient_state bit 0 ---- -if ((po->transient_state & 1) == 0) return; // line 353080 - -int type = head_->type; -if (type == 2 || type == 3) return; // velocity nodes → skip - -// ---- Distance to head ---- -float dist = Position::distance(&po->m_position, &head_[2 /* node->p Position */]); -if (dist <= DESIRED_DISTANCE /* 0.05 */) { // line 353089 - NodeCompleted(this, 1); // pop head, advance - return; -} - -// ---- Catch-up speed ---- -float catchUp; -if (po->minterp() != 0) { - float maxSpd = fUseAdjustedSpeed_ - ? CMotionInterp::get_adjusted_max_speed(po->minterp()) - : CMotionInterp::get_max_speed(po->minterp()); - catchUp = maxSpd * MAX_INTERPOLATED_VELOCITY_MOD; // 2.0 -} else { - catchUp = 0f; -} -// F_EPSILON test: if catchUp < 0.0002 → fallback to MAX_INTERPOLATED_VELOCITY (7.5) -if (catchUp < 0.000199999995f) catchUp = 7.5f; // line 353128 / 0x40f00000 - -// ---- Accumulate progress + frame counter ---- -this->progress_quantum += (float)arg3; // ← see note below -this->frame_counter += 1; - -// ---- 5-frame stall window ---- -if (this->frame_counter >= 5) { - float cumulative = this->original_distance - dist; // line 353150 - if (CPhysicsObj::get_sticky_object_id(po) == 0) { - bool primary_pass = cumulative >= MIN_DISTANCE_TO_REACH_POSITION; // 0.20 - bool secondary_pass = cumulative > F_EPSILON - && (cumulative / progress_quantum / arg3) >= CREATURE_FAILED_INTERPOLATION_PERCENTAGE; // 0.30 - // EITHER pass → window is good. NEITHER pass: - if (!primary_pass && !secondary_pass) { - this->node_fail_counter += 1; - NodeCompleted(this, 0); // re-baseline, do NOT stop - return; - } - } - this->frame_counter = 0; - this->progress_quantum = 0f; - this->original_distance = dist; // re-baseline window -} - -// ---- Compute step Frame ---- -Vector3 toHead; -Position::subtract2(&head_[2], &delta_frame, &po->m_position); // delta_frame.origin = head - here (cell-aware) -toHead = delta_frame.m_fOrigin; - -float step = catchUp * (float)arg3; // catchUp m/s * dt s = step m - -float toHead_mag = AC1Legacy::Vector3::magnitude(&toHead); - -// ---- Reach test (different threshold!) ---- -if (toHead_mag <= DESIRED_DISTANCE /* 0.05 */) // line 353222 (note: tested INSIDE this branch too) - NodeCompleted(this, 1); - -// ---- No-overshoot scale ---- -if (step < toHead_mag) { - float scale = step / toHead_mag; - Vector3::operator*=(&toHead, scale); // shrink toHead to length=step -} -// else: leave toHead at full magnitude (step would overshoot — clamped to dist) - -// ---- Heading override ---- -if (this->keep_heading != 0) { - Frame::set_heading(&delta_frame, 0f); // zero rotation in the offset Frame -} - -// ---- Output ---- -Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame -``` - -### NOTE on `progress_quantum` - -Look at lines 353139–353143 again carefully: - -``` -00555e01 /* fld qword [esp+0x60] */; ; load arg3 (dt as double, 8 bytes) -00555e05 /* fadd dword [esi+0x18] */; ; add this->progress_quantum (float) -00555e08 uint32_t edx_3 = (this->frame_counter + 1); -00555e0e this->progress_quantum = ((float)/* fstp dword [esi+0x18] */); -``` - -The accumulator is `progress_quantum += dt` (sum of frame deltas), **NOT** `progress_quantum += step`. This contradicts the current acdream port (`_progressQuantum += step;` line 289 of InterpolationManager.cs). **This is a real bug.** - -Then at the secondary-stall test (line 353169-353171): - -``` -00555e68 /* fld dword [esp+0x14] */; ; cumulative -00555e6c /* fdiv dword [esi+0x18] */; ; / progress_quantum (= sum_dt) -00555e6f /* fdiv dword [esp+0xc] */; ; / arg3 (current dt) -00555e73 compare against CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30) -``` - -So the secondary fail check is `cumulative / sum_dt / current_dt < 0.30`. Equivalently: **average velocity over the window divided by current dt < 0.30**, which has units of `1/seconds`. This is a numerically odd formula and feels like a Turbine bug or x87-stack misread by Binary Ninja, but **we must port it verbatim**. - -### Constants table - -| Constant | Symbol | Value | Cited line | -|---|---|---:|---| -| `MAX_PHYSICS_DISTANCE` | gate in MoveOrTeleport | 96.0 m | (cdb-confirmed; not in adjust_offset itself) | -| `CREATURE_OUTSIDE_BLIP_DISTANCE` | GetAutonomyBlipDistance | 100.0 m | (cdb) | -| `CREATURE_INSIDE_BLIP_DISTANCE` | GetAutonomyBlipDistance | 20.0 m | (cdb) | -| `MAX_INTERPOLATED_VELOCITY_MOD` | maxSpd × this | 2.0 | implicit `* 2f` line 353122 | -| `MAX_INTERPOLATED_VELOCITY` | fallback m/s | 7.5 | `0x40f00000` line 353137 | -| `MIN_DISTANCE_TO_REACH_POSITION` | primary stall thresh | 0.20 m | line 353185 (cited as `&MIN_DISTANCE_TO_REACH_POSITION`) | -| `DESIRED_DISTANCE` | reach + prune | 0.05 m | line 353222 (cited as `&DESIRED_DISTANCE`) | -| `CREATURE_FAILED_INTERPOLATION_PERCENTAGE` | secondary stall ratio | 0.30 | line 353172 (cited as `&CREATURE_FAILED_INTERPOLATION_PERCENTAGE`) | -| `F_EPSILON` | catchUp / cumulative test | 0.0002 | lines 353127, 353156 (cited as `&F_EPSILON`) | -| `StallCheckFrameInterval` | window length | 5 | line 353146 (`>= 5`) | -| `StallFailCountThreshold` | tail-blip trigger | 3 | line 353270 (`> 3` in UseTime) | -| `fUseAdjustedSpeed_` | static toggle | 1 | line 1102675 | - -### Question: what does `physics_obj->transient_state & 1` gate? - -Bit 0 of `transient_state`. From cross-references in the larger codebase, `transient_state & 1` is set when the body is in a state where its position should be advancing (i.e., it has been initialized into the world AND is not in a frozen / parked / teleporting state). When the bit is **clear**, retail short-circuits adjust_offset and returns without consuming any window time. This avoids the body interpolating during portal transitions, fades, etc. - -Our acdream port has no equivalent gate. For the L.3 work this is probably safe (PhysicsBody is always "live" once spawned) but worth filing as a follow-up if we ever see an entity interpolating across a teleport. - ---- - -## 5. `InterpolationManager::NodeCompleted` @ `0x005559a0` - -Signature: `void NodeCompleted(InterpolationManager *this, int32_t arg2)` -- `arg2 == 1` → "real" completion (head reached target). If queue empties, also calls `StopInterpolating`. -- `arg2 == 0` → "stall" completion (re-baseline only; do NOT clear queue). - -```c -if (this->physics_obj == 0) return; - -LListData *old_head = this->position_queue.head_; -this->frame_counter = 0; -this->progress_quantum = 0f; -LListData *popped = nullptr; - -// Pop old head off the front of the singly-linked list -if (old_head != 0) { - LListData *next = old_head->llist_next; - this->position_queue.head_ = next; - if (next == 0) this->position_queue.tail_ = nullptr; - old_head->llist_next = 0; - popped = old_head; -} - -LListData *new_head = this->position_queue.head_; -if (new_head == 0) { - this->original_distance = 999999f; // empty queue → reset baseline sentinel - if (arg2 != 0) { // real completion - StopInterpolating(this); - goto FREE_POPPED; - } - if (popped != 0) { - // arg2 == 0 (stall) AND queue empty AFTER pop: - // copy popped node's position into blipto_position so that - // UseTime can blip there if the fail counter trips. - Position::operator=(&this->blipto_position, &popped->p); - } -} else if (new_head->type != 1) { - // Velocity node up next — don't re-baseline distance. - if (arg2 != 0) goto FREE_POPPED; - // arg2 == 0 (stall) AND non-position next: still snapshot the popped - // position into blipto_position for tail-blip use. - if (popped != 0) - Position::operator=(&this->blipto_position, &popped->p); -} else { - // Normal case: new head is a position node; rebaseline distance. - this->original_distance = (float)Position::distance( - &this->physics_obj->m_position, &new_head->p); -} - -FREE_POPPED: -if (popped != 0) { - *(int32_t*)((char*)popped + 8) = 0x79285c; - operator delete(popped); -} -``` - -**Queue ops**: pop is HEAD (FIFO). Memory is freed. The popped node's position is snapshotted into `blipto_position` when the queue empties under stall — that's the **blip-to-tail-on-stall** target. (Calling it "blip-to-tail" is a slight misnomer; it's "blip to the position we last failed to reach" when the queue has emptied.) - ---- - -## 6. `InterpolationManager::UseTime` @ `0x00555f20` - -Signature: `void UseTime(InterpolationManager *this)`. No params. Called every physics tick from `PositionManager::UseTime`. - -```c -CPhysicsObj *po = this->physics_obj; -if (po == 0) return; - -int fail = this->node_fail_counter; -if (fail > 3) goto BLIP_BRANCH; // line 353270 — threshold check - -// ---- Normal branch: process head node ---- -LListData *head_ = this->position_queue.head_; -if (head_ != 0) { - int type = head_->type; - if (type == 3) { - // Velocity node: write velocity, complete. - CPhysicsObj::set_velocity(po, &head_[0x14 /*Vector3 at +0x50*/], 1); - NodeCompleted(this, 1); - return; - } - if (type == 2) { - // Type-2: just complete (no velocity write). - NodeCompleted(this, 1); - } - // type == 1 → no-op here; adjust_offset moves the body each frame. -} else if (fail > 0) { - // No queue but a recent failure → fall through to BLIP_BRANCH - goto BLIP_BRANCH; -} -return; - -BLIP_BRANCH: -// ---- "Snap" branch ---- -LListData *tail_ = this->position_queue.tail_; -Position target; -bool reapply_velocity = false; -Vector3 saved_vel; - -if (tail_ == 0) { - // No tail → blip to the snapshot stashed in blipto_position by NodeCompleted. - target = this->blipto_position; -} else if (tail_->type == 2 || tail_->type == 3) { - // Tail is a velocity node. Walk the list looking for the LAST type-1 - // (position) node before the tail. Save the tail's velocity. - saved_vel = *(Vector3*)((char*)tail_ + 0x50); - LListData *cur = this->position_queue.head_; - bool found_pos = false; - Position last_pos; - while (cur != tail_) { - if (cur->type == 1) { - last_pos = cur->p; - found_pos = true; - } - cur = cur->llist_next; - } - if (!found_pos) { - // No position to blip to — fall back to blipto_position. - target = this->blipto_position; - } else { - target = last_pos; - reapply_velocity = true; - } -} else { - // Tail is a position node — blip to it directly. - target = tail_->p; -} - -// ---- The actual snap ---- -if (CPhysicsObj::SetPositionSimple(po, &target, 1) == OK_SPE) { - if (reapply_velocity) { - CPhysicsObj::set_velocity(po, &saved_vel, 1); - } - StopInterpolating(this); -} -``` - -### Answers to the critical questions - -- **Does UseTime call SetPositionSimple for the blip?** **Yes** — line 353282 (`CPhysicsObj::SetPositionSimple(physics_obj, var_70_3, 1)`). -- **Tail blip target** is normally the queue's tail node (the most recent server position). When the tail is a velocity node, the algorithm walks back to the last position node. When the queue is empty, `blipto_position` (set by NodeCompleted on prior pop) is used. -- **`StopInterpolating` is called only on successful `SetPositionSimple`.** If SetPositionSimple fails (cell transition rejected, etc.), state is preserved and we'll retry next tick. - ---- - -## 7. Cross-check against current acdream port - -`src/AcDream.Core/Physics/InterpolationManager.cs`: - -| Behavior | Current port | Retail | Verdict | -|---|---|---|---| -| Queue is FIFO with cap 20 | Yes (`LinkedList`, `RemoveFirst` on cap) | Yes | OK | -| Duplicate-prune on enqueue | Compares to `_queue.Last` only | Walks tail-prune loop | **Functional match** for single enqueues; would diverge if multiple stale tail entries exist (rare in practice). | -| `Enqueue` "force blip" via `node_fail_counter = 4` on far-distance | Missing | Line 352944 sets `node_fail_counter = 4` | **Gap.** Far enqueues should pre-arm an immediate blip on the next tick. Probably manifests as "remote drifts visibly toward a far target instead of teleporting" when a 100m+ desync is enqueued. Real-world rare; file as follow-up. | -| reach test against head | `dist < DesiredDistance` → pop, return Vector3.Zero | line 353089 `dist <= DESIRED_DISTANCE` → `NodeCompleted(1)`, return | OK | -| reach test #2 against `toHead.magnitude` | Not separated | line 353222 inside the step branch | Both reach against the same scalar; equivalent | -| catchUp = max(maxSpd*2, fallback 7.5) | OK (`scaled > 1e-6f`) | OK (`F_EPSILON 0.0002`) | Threshold tighter (1e-6 vs 2e-4); still functional. | -| Step clamp (no overshoot) | `step = min(step, dist)` | Same (else-branch leaves toHead full mag) | OK | -| **`progress_quantum += step`** | `_progressQuantum += step;` | `progress_quantum += dt;` (line 353140) | **Bug.** Retail accumulates *time*, not distance. The secondary check then divides by this time-sum AND by current dt. Our port computes a different ratio. | -| Secondary stall: `cumulative / progress_quantum < 0.30` | Yes | Retail computes `cumulative / sum_dt / cur_dt < 0.30` (units: 1/sec) | **Off by a /dt factor.** Retail's formula is suspect but we should port verbatim and add a regression test. | -| frame_counter | `_framesSinceLastStallCheck` | Equivalent | OK | -| First-window guard | `_haveBaselineDistance` flag | `original_distance = 999999f` sentinel | Equivalent | -| Re-baseline at window end | `_distanceAtWindowStart = dist` | Same | OK | -| **Stall fail action** | Increments fail counter; only blips when threshold exceeded INSIDE adjust_offset | Calls `NodeCompleted(0)` on stall and pops the head; UseTime does the actual blip | **Architectural gap.** Retail's NodeCompleted(0) on stall pops the head node (advancing the queue) — useful when the head is unreachable but later nodes might be. Our port leaves the head in place. This means a single bad waypoint can cause repeated 5-frame failures rather than skipping past it. | -| Blip target on threshold | `_queue.Last.TargetPosition` (tail) | UseTime: tail OR blipto_position OR last-pos-before-velocity-tail | Mostly OK for our use case (only type-1 nodes). | -| `transient_state & 1` gate | None | Required | Probably safe in our codebase; file as follow-up. | -| AdjustOffset return shape | `Vector3` delta | In-place mutate `Frame*` (translation-only Frame) | Functionally equivalent — caller composes with current position either way. | - -### Concrete actionable changes - -1. **Rename** `progressQuantum` semantics: `_progressQuantum += (float)dt;` (NOT step). -2. **Rewrite the secondary check** as `(cumulative / sum_dt) / cur_dt < 0.30` to match retail verbatim. -3. **Add `NodeCompleted(0)` semantics**: on stall, pop the head node, snapshot its position into a `_blipToPosition` field, but do NOT clear the queue and do NOT return a snap delta. The blip then fires only via the equivalent of UseTime when `_failCount > 3`. -4. **Split `UseTime` from `AdjustOffset`**: retail's UseTime is what *actually performs the snap*. AdjustOffset only moves the body. Currently we conflate them — `AdjustOffset` returns the snap delta itself when fail count exceeds threshold. The two-phase split would let us call SetPositionSimple-equivalent (PhysicsBody.SetPositionSimple) once per tick and not mid-frame. -5. **On far-distance enqueue** (`dist > GetAutonomyBlipDistance()` for the entity's cell type), set `_failCount = StallFailCountThreshold + 1` so the next tick's UseTime triggers a blip to the freshly-enqueued tail. -6. **Optionally gate on a `_isLive` flag** equivalent to `transient_state & 1` once L.3 lands and we have callers that might enable interpolation across teleports. - ---- - -## 8. `position_queue` data-structure operations summary - -| Op | Implementation | -|---|---| -| **Enqueue tail** | `tail_->llist_next = new`; `tail_ = new`; if head was null, head = new too. (lines 352942-352950, 353055-353065) | -| **Pop head** | `head_ = head_->llist_next`; if new head null, `tail_ = null`; delete old head. (lines 352774-352782) | -| **RemoveTail** (`LListBase::RemoveTail`) | Used inside InterpolateTo's tail-prune (line 352995). Retail external symbol. | -| **Walk-and-count for cap** | Lines 353012-353017. | -| **Walk-find for last position before velocity tail** | Lines 353312-353323 inside UseTime. | -| **Drain (StopInterpolating / Destroy)** | Same loop, both functions. | - -The list is **singly-linked**, head + tail pointers, no prev. Insertion-at-tail and removal-at-head are O(1). RemoveTail and the find-last-position walks are O(N) but N ≤ 20. - ---- - -## 9. Bibliography - -- Pseudo-C lines: 352695 (ctor) – 353384 (dtor) of - `docs/research/named-retail/acclient_2013_pseudo_c.txt`. -- Struct layout: `docs/research/named-retail/acclient.h` line 31505. -- `fUseAdjustedSpeed_` static: line 1102675 of pseudo-C. -- Caller `CPhysicsObj::UpdatePartsInternal` @ ~`0x00512c30`, where the - Frame returned from `adjust_offset` is composed with `m_position.frame` - via `Frame::combine`. -- cdb live-trace results: `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`. -- Existing port: `src/AcDream.Core/Physics/InterpolationManager.cs`. diff --git a/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md b/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md deleted file mode 100644 index 28d573a6..00000000 --- a/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md +++ /dev/null @@ -1,491 +0,0 @@ -# L.3 port — PositionManager + CPartArray::Update + CSequence root motion - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` -(Sept-2013 EoR build, Binary Ninja decomp, PDB-named). - -This note pins down where retail's per-tick "animation root motion" -actually comes from, what `PositionManager::adjust_offset` adds on top -of it, and exactly what each manager writes into the per-tick `Frame`. - -It exists to settle one question: **does retail's `CPartArray::Update` -produce per-keyframe pos-frame deltas (a.k.a. baked root motion in the -animation data), or does it integrate `CSequence::velocity * dt` (a -constant-velocity model), or both?** The answer is **both**, in a -strict order, and acdream's current C# port only models the second -half. - ---- - -## 0. Top-level call site — `CPhysicsObj::UpdatePositionInternal` - -`@ 0x00512c30` (line 280817): - -```c -void __thiscall CPhysicsObj::UpdatePositionInternal( - class CPhysicsObj* this, float arg2 /* dt */, class Frame* arg3 /* out */) -{ - Frame var_40; // 1. local Frame, identity - Frame::cache(&var_40); // var_40 = identity - - if ((state & 0x4000) == 0) { // not animation-paused - if (this->part_array != 0) - CPartArray::Update(this->part_array, arg2, &var_40); // (A) - // ... var_c/var_8/var_4 scaled by m_scale (joint-frame stuff, - // not the root) ... - } - - if (this->position_manager != 0) - PositionManager::adjust_offset(this->position_manager, &var_40, arg2); // (B) - - Frame::combine(arg3, &this->m_position.frame, &var_40); // (C) - - if ((state & 0x4000) == 0) - CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); // (D) — sweep/collision - CPhysicsObj::process_hooks(this); -} -``` - -So the per-tick recipe is: - -1. **var_40 = identity Frame** -2. **(A)** `CPartArray::Update(dt, &var_40)` writes the animation-driven - delta into var_40 (origin + orientation). -3. **(B)** `PositionManager::adjust_offset(&var_40, dt)` fans out to - `InterpolationManager::adjust_offset`, `StickyManager::adjust_offset`, - `ConstraintManager::adjust_offset`, each of which mutates var_40 - in-place. -4. **(C)** Result frame = `m_position.frame ∘ var_40` (rotation - composes, then translates). -5. **(D)** Sweep/collision (the call we already port as - `ResolveWithTransition`). - -`var_40` is *both* origin (`m_fOrigin`) and orientation (`m_angles` / -`Frame::rotate`). It is a delta, not a position. - ---- - -## 1. `CPartArray::Update` is a 1-line forwarder - -`@ 0x00517db0` (line 285883): - -```c -void __thiscall CPartArray::Update(class CPartArray* this, float arg2 /* dt */, class Frame* arg3) -{ - CSequence::update(&this->sequence, (double)arg2, arg3); -} -``` - -All the work is in `CSequence::update`. - ---- - -## 2. `CSequence::update` — 1-line gatekeeper - -`@ 0x00525b80` (line 302402): - -```c -void __thiscall CSequence::update(class CSequence* this, double arg2 /* dt */, class Frame* arg3) -{ - if (this->anim_list.head_ != 0) { - CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3); - CSequence::apricot(this); // remove finished non-cyclic anims from list - return; - } - if (arg3 != 0) - CSequence::apply_physics(this, arg3, arg2 /*dt*/, arg2 /*sign-dt*/); -} -``` - -If there are NO animations queued, `apply_physics` runs once with -`(dt, dt)` and writes velocity·dt into the frame directly. Otherwise -the inner loop drives both per-keyframe combine AND apply_physics. - ---- - -## 3. `CSequence::update_internal` — the keyframe loop (THIS IS THE ROOT MOTION SOURCE) - -`@ 0x005255d0` (line 301839). I'll show the structurally important -parts; the FCOMP/FLD ops are FPU translation noise from Binary Ninja -and read like English once you ignore them: - -Branching on the sign of `arg2` (dt — positive = forward, negative = -playing the cycle in reverse) the function picks one of two near- -identical inner loops. - -### 3a. Forward branch (arg2 ≥ 0) — `else` block at 0x00525646 - -```c -// floor(frame_number) → ebx_2 = integer keyframe index -do { - if (arg5 /*Frame*/ != 0) { - AnimSequenceNode* node = *arg3; // current animation node - if (node->anim->pos_frames != 0) { - // (A1) MULTIPLY-ACCUMULATE the dat-baked pos-frame for keyframe ebx_2 - // into the running Frame: - Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_2)); - } - // If the animation has nonzero framerate (|fr| > F_EPSILON): - // (A2) integrate velocity·omega over the time spent on THIS keyframe - // dt_keyframe = 1.0 / framerate - // apply_physics(this, arg5, dt_keyframe, arg2_total_dt); - if (|framerate| > F_EPSILON) { - double dt_keyframe = 1.0 / framerate; - CSequence::apply_physics(this, arg5, dt_keyframe, arg2); - } - } - CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_2), 1 /*forward*/); - ebx_2 += 1; - // re-test loop: continue while frame_number > ebx_2 (we have more keyframes - // worth of time-budget to consume this tick). -} while (frame_number > ebx_2); -``` - -### 3b. Backward branch (arg2 < 0) — `if` block at 0x00525646 - -Mirror image of the forward branch: - -```c -do { - if (arg5 != 0) { - AnimSequenceNode* node = *arg3; - if (node->anim->pos_frames != 0) { - // (A1') SUBTRACT the dat-baked pos-frame for keyframe ebx_1 - // (Frame::subtract1, not Frame::combine) - Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_1)); - } - if (|framerate| > F_EPSILON) { - double dt_keyframe = 1.0 / framerate; - CSequence::apply_physics(this, arg5, dt_keyframe, arg2 /*negative*/); - } - } - CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_1), -1 /*backward*/); - ebx_1 -= 1; -} while (frame_number < ebx_1); // walk indices DOWN -``` - -When the inner loop completes the time budget, `frame_number` is -updated to the new fractional position and (if the cycle ended) -`advance_to_next_animation` rolls the queue forward. - -### 3c. Special "no time elapsed" path - -If `|arg2| < F_EPSILON` (dt ≈ 0) the function still calls -`apply_physics(this, arg5, dt /*≈0*/, arg2)` once and returns — -ensures velocity·0 = 0 and omega·0 = 0 are written even when no -keyframe boundary is crossed. - -### Per-keyframe vs per-tick - -The crucial structural fact: the loop runs **once per integer keyframe -that fits inside the tick's time budget**. If an animation runs at -30 fps and we tick at 60 Hz, most ticks consume ZERO keyframes -(the loop body never executes); the time accumulates in `frame_number` -until the next keyframe boundary. When a keyframe is crossed, -`Frame::combine(frame, frame, pos_frame)` is invoked AND -`apply_physics` is invoked with `dt = 1/framerate` (NOT the tick's -real dt). Across many ticks this averages to integrating velocity at -the cycle's framerate, but on a single tick the integration may be 0 -or it may be 1/framerate or it may be N/framerate for fast cycles. - -This is **important for our port**: when our C# code does -`bodyPos += seqVel * dt` per tick at fixed 60 Hz, we are smoothing the -retail behavior. That's fine for steady motion but explains why the -retail trace shows "stairsteps" of pos updates aligned to keyframe -boundaries — it really is per-keyframe. - ---- - -## 4. `CSequence::apply_physics` — the velocity integrator - -`@ 0x00524ab0` (line 300955): - -```c -void __thiscall CSequence::apply_physics( - class CSequence const* this, - class Frame* arg2, // mutated - double arg3, // dt magnitude (always positive in the loop) - double arg4) // sign carrier (positive = forward, negative = backward) -{ - long double scale = fabs((long double)arg3); // |dt| - if (arg4 < 0.0) - scale = -scale; // negate for backward play - - arg2->m_fOrigin.x += (float)(scale * this->velocity.x); - arg2->m_fOrigin.y += (float)(scale * this->velocity.y); - arg2->m_fOrigin.z += (float)(scale * this->velocity.z); - - Vector3 axisAngle = { - scale * this->omega.x, - scale * this->omega.y, - scale * this->omega.z - }; - Frame::rotate(arg2, &axisAngle); // arg2->m_angles = axisAngle ∘ arg2->m_angles -} -``` - -So one call writes BOTH translation (origin += scale·velocity) and -rotation (`Frame::rotate` = quat-from-axis-angle ∘ existing). The -sign of `arg4` determines whether we play forward or backward; the -*magnitude* in arg3 is the dt being integrated (1/framerate per -keyframe inside `update_internal`). - -`this->velocity` is `CSequence::velocity` (set by `add_motion` from -`MotionData::velocity * style_speed`). `this->omega` is -`CSequence::omega` (same source). - ---- - -## 5. Where does `CSequence::velocity` come from? - -`add_motion` `@ 0x005224b0` (line 298437): - -```c -void add_motion(CSequence* arg1, MotionData* arg2 /*dat-loaded*/, float arg3 /*style_speed*/) -{ - if (arg2 == 0) return; - - Vector3 vel = { - arg3 * arg2->velocity.x, - arg3 * arg2->velocity.y, - arg3 * arg2->velocity.z - }; - CSequence::set_velocity(arg1, &vel); // overwrites — not additive - - Vector3 omg = { - arg3 * arg2->omega.x, - arg3 * arg2->omega.y, - arg3 * arg2->omega.z - }; - CSequence::set_omega(arg1, &omg); - - // append each anim segment (the actual cyclic / link / ack list) - for (int i = 0; i < arg2->num_anims; i++) - CSequence::append_animation(arg1, - operator*(&__return, arg3, &arg2->anims[i])); -} -``` - -So the answer to the brief's first set of critical questions: - -> For locomotion cycles (Walk, Run), is the root motion baked into -> PosFrames in the animation data, OR computed from MotionData.Velocity? - -**Both, simultaneously.** The retail data ships SOME motions with -nonzero `MotionData::velocity` (which becomes per-keyframe -`scale·velocity` translation through `apply_physics`) AND/OR with -nonzero `CAnimation::pos_frames[i]` (per-keyframe explicit deltas -combined into the frame via `Frame::combine`). For Humanoid run/walk, -ACE's port and our existing diagnostics agree the dat ships -`HasVelocity = 0`, meaning the dat-side `MotionData::velocity` is -zero. The actual per-keyframe pos_frames are also typically tiny -(stride wobble) — which is why retail clients ALSO drive -`CMotionInterp::get_state_velocity` (RunAnimSpeed × ForwardSpeed) into -`CSequence::velocity` via a separate path during locomotion. Our -synthesized `CurrentVelocity` in `AnimationSequencer.SetCycle` -(WalkAnimSpeed=3.12, RunAnimSpeed=4.0, etc.) mirrors this exactly. - -> For idle cycles (Ready), is the root motion zero? - -Yes — Ready's `MotionData::velocity` is zero, and our synthesizer -leaves `CurrentVelocity` at zero for non-locomotion cycles. ✓. - -> For sign-flipped backward (cycle plays in reverse), is root motion negated? - -Yes — `apply_physics`'s `arg4 < 0` branch negates `scale`, so origin -delta and rotation delta both flip. Our port handles WalkBackward by -going through the MotionInterpreter's `adjust_motion` remap to -WalkForward + speedMod×−0.65 (matches retail's actual encoding); the -backward keyframe-loop branch is reachable for cyclic anims that -genuinely play with negative framerate. - ---- - -## 6. `PositionManager::adjust_offset` — fan-out - -`@ 0x00555190` (line 352090): - -```c -void __thiscall PositionManager::adjust_offset( - class PositionManager* this, class Frame* arg2 /*the var_40 from above*/, double arg3 /*dt*/) -{ - if (this->interpolation_manager != 0) - InterpolationManager::adjust_offset(this->interpolation_manager, arg2, arg3); - if (this->sticky_manager != 0) - StickyManager::adjust_offset(this->sticky_manager, arg2, arg3); - if (this->constraint_manager != 0) - ConstraintManager::adjust_offset(this->constraint_manager, arg2, arg3); -} -``` - -ORDER MATTERS. Each manager mutates `arg2` in-place. - -### 6a. `InterpolationManager::adjust_offset` (`@ 0x00555d30`, line 353071) - -This is the head-of-queue catch-up logic the user already agonized -over. The behavior: - -- If position_queue is empty → no-op. -- If transient_state lacks bit 1 → no-op. -- If queue head has special types 2 or 3 → no-op. -- If `Position::distance(physics_obj, head_target) < 0.05f` → - `NodeCompleted(true)` and **return** (arg2 untouched — animation - root motion stands). -- Otherwise: - - `max_speed = (fUseAdjustedSpeed_ ? get_adjusted_max_speed - : get_max_speed) * 2.0f`. - - Build a unit direction toward head, scaled by - `min(max_speed × dt, distance)`, **OVERWRITE arg2->m_fOrigin** with - that vector. Animation root motion for THIS tick is discarded. - -So `InterpolationManager::adjust_offset` is **either** a pure pass- -through (close-enough) or a **REPLACE** (overwrite arg2->m_fOrigin). -It is NOT additive. Our `PositionManager.cs` correctly implements -this dichotomy in `ComputeOffset`. - -### 6b. `StickyManager::adjust_offset` (`@ 0x00555430`, line 352351) - -When sticky-target-id is set and initialized: - -- Compute world-space offset to target (via `Position::get_offset`), - store in `arg2->m_fOrigin`. -- Convert to local-space (`Position::globaltolocalvec`), - zero the Z (stay-at-target-altitude only in XY). -- Distance = `cylinder_distance_no_z - 0.30f`. -- If the offset normalized fine: scale it by - `min(max_speed * dt, |distance|)` and write back. (Same - movement-budget logic as InterpolationManager but toward a - different target.) -- Then `Frame::set_heading(arg2, target_heading − current_heading)` — - i.e., **OVERWRITES** arg2's heading too. - -`StickyManager::adjust_offset` runs AFTER `InterpolationManager` so it -can REPLACE the interpolation correction. This makes sense: sticky -follow-target is a higher-priority constraint than queued node-by-node -movement. - -### 6c. `ConstraintManager::adjust_offset` (`@ 0x00556180`, line 353479) - -When `is_constrained != 0` and `transient_state & 1`: - -- If `constraint_pos_offset > constraint_distance_max`: zero out - arg2->m_fOrigin (clamp to constraint). -- If `constraint_pos_offset > constraint_distance_start`: scale - arg2->m_fOrigin by `(max - offset) / (max - start)` (linear ease-out - near the cap). -- Otherwise: leave arg2->m_fOrigin alone. -- Always: accumulate arg2->m_fOrigin.x into `this->constraint_pos_offset` - (advance the offset tracker). - -So Constraint is the only manager that's **purely scalar**: it scales -or zeros `arg2->m_fOrigin` rather than overwriting it. - -### Summary of the fan-out - -| Manager | What it writes to `arg2` | Conditions | -|---|---|---| -| `InterpolationManager` | OVERWRITES origin with catch-up vector OR no-op | head-of-queue distance > 0.05 | -| `StickyManager` | OVERWRITES origin AND heading with chase-target vector | target_id != 0 AND initialized | -| `ConstraintManager` | SCALES (or zeros) origin, never writes new value | is_constrained AND transient bit | - -If multiple are active at once they compose, but the natural retail -case is at most one of (Interp, Sticky) active per object — Sticky is -typically used for combat lock / charge-target follows; Interp is the -default for queued moveto. - ---- - -## 7. Cross-check vs acdream's port - -### `src/AcDream.Core/Physics/PositionManager.cs` - -acdream's port collapses the entire chain to: - -```csharp -Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); -if (correction.LengthSquared() > 0f) - return correction; - -Vector3 rootMotionLocal = seqVel * (float)dt; -return Vector3.Transform(rootMotionLocal, ori); -``` - -Divergences from retail: - -1. **No StickyManager / ConstraintManager.** Currently fine for L.3 - (we don't ship sticky-follow yet); flag for L.5+ when combat - targeting lands. - -2. **Single `seqVel * dt` per tick instead of per-keyframe.** - Retail's loop runs once per integer keyframe boundary inside the - tick, calling `apply_physics(dt = 1/framerate)` each time. Our - port runs once per tick at `dt = tick`. Net displacement per - second is identical for steady-state running, but the retail - trace will show "stairsteps" aligned to keyframe boundaries - while ours will show smooth integration. This is probably the - cause of the user-reported "staircase" pattern when remotes run - up/down slopes — every keyframe boundary, retail does a discrete - `Frame::combine(pos_frame_delta)` then a discrete velocity bump. - We integrate continuously and miss the per-keyframe pos_frame - delta entirely. - -3. **`pos_frames` from the dat are completely ignored.** Retail's - `Frame::combine(arg5, arg5, get_pos_frame(node, kf))` per keyframe - is the dat-baked stride wobble / hand-position-during-cast / etc. - For Humanoid locomotion these are small but nonzero — likely - ±0.02m wobble plus Z bob. Ignoring them makes our remote bodies - glide unnaturally smoothly. - -4. **`Frame::rotate` from `omega·dt` is partially handled** — our - `CurrentOmega` synth covers turn cycles (TurnRight/TurnLeft) and - `RemoteEntity` integrates omega into its quaternion per tick. ✓. - -### `src/AcDream.Core/Physics/AnimationSequencer.cs` - -Lines 614–679 synthesize `CurrentVelocity` and `CurrentOmega` for -locomotion / turn cycles using the retail `RunAnimSpeed=4.0`, -`WalkAnimSpeed=3.12`, `SidestepAnimSpeed=1.25`, omega `±π/2`. These -constants match `_DAT_007c96e0/e4/e8` from the older Ghidra decomp -and the named-retail symbols. ✓. - -What we DON'T mirror: - -- The `MotionData::velocity` × `style_speed` MULTIPLY through - `add_motion`. Retail computes `CSequence::velocity = style_speed * - MotionData.velocity`; our synth uses `RunAnimSpeed * adjustedSpeed` - directly. For Humanoid this is correct because the dat's - `MotionData.velocity` is zero so the multiply is a no-op anyway — - but for creatures with nonzero `MotionData.velocity`, our synth - silently drops that contribution. Filed as future-port concern; - currently no observed impact. - -- Per-keyframe `pos_frames` deltas (see #3 above). Our - `CurrentVelocity` carries only the *steady-state* component of the - cycle's intent; the per-frame stride wobble is gone. To capture - it we'd need to walk `CAnimation.PosFrames[i]` and add the keyframe - delta on each integer-keyframe-boundary tick — i.e., port the - inner loop of `update_internal` rather than collapsing it to a - velocity number. - ---- - -## 8. Recommendations for L.3 follow-up - -Likely root cause of the remote-run-on-slope staircase regression -(env-var path) and the steady-state position blips: - -1. The env-var path bypasses `ResolveWithTransition` (already fixed - in commit 039149a, per memory). ✓. -2. The remote body integrates `seqVel * dt` per tick smoothly, while - broadcasts arrive at ~5 Hz with retail's per-keyframe-discretized - advance. Mismatch shows as small +/- Z bob between UPs. -3. `pos_frames` deltas ignored — Z stride wobble lost. -4. `omega` integration order vs `Frame::combine(pos_frame)` — retail - does `Frame::combine(pos_frame)` BEFORE `apply_physics`, so the - pos_frame's heading rotation applies first; we do them in either - order depending on caller wiring. - -Before implementing more porting, brainstorm with `superpowers: -brainstorming` whether per-keyframe integration is worth porting now -(complexity: high; visible impact: stride wobble; user-visibility: -probably low) versus accepting the smoothed model and instead tuning -the InterpolationManager catch-up thresholds. diff --git a/docs/research/2026-05-04-l3-port/06-acdream-audit.md b/docs/research/2026-05-04-l3-port/06-acdream-audit.md deleted file mode 100644 index b21802ae..00000000 --- a/docs/research/2026-05-04-l3-port/06-acdream-audit.md +++ /dev/null @@ -1,550 +0,0 @@ -# 06 — Acdream audit: player remote-entity motion code as it stands - -Date: 2026-05-04. Scope: every piece of code that touches a *remote* player -character's motion between `OnLiveMotionUpdated`, `OnLiveVectorUpdated`, -`OnLivePositionUpdated`, and the per-tick `TickAnimations` loop. Inputs: - -- `src/AcDream.App/Rendering/GameWindow.cs` (8346 LOC). -- `src/AcDream.Core/Physics/{MotionInterpreter,AnimationSequencer, - PhysicsBody,InterpolationManager,PositionManager, - ServerControlledLocomotion,RemoteMoveToDriver}.cs`. - -Verdict labels used below: **PORT** (faithful retail port, retail address -cited), **HACK** (acdream-original logic with no retail equivalent), or -**BROKEN** (regressed/wrong vs. the retail spec; see ISSUES.md / file -header comments). - ---- - -## 1. `GameWindow.RemoteMotion` (lines 224–432) - -Per-remote nested struct stored on `_remoteDeadReckon[serverGuid]`. One -allocation per remote, lives until despawn. Owns: - -| Field | Verdict | Notes | -|---|---|---| -| `PhysicsBody Body` | PORT | Retail `CPhysicsObj` (acclient @0x00510000 region). Constructed with `Contact|OnWalkable|Active` flags and `ReportCollisions` state — gravity OFF by default, "remotes don't simulate gravity" comment at L420. Per L.3 spec this is **the correct retail invariant**. | -| `MotionInterpreter Motion` | PORT | Retail `CMotionInterp`. Body wired in ctor. | -| `LastServerPosTime/LastServerPos` | HACK | Diagnostic + dt-source for the legacy `update_object` path. | -| `ServerVelocity / HasServerVelocity` | HACK | Acdream-only: synthesises velocity from `(pos - prevPos)/dt` because ACE rarely sets HasVelocity on player UPs. Used only by `ApplyServerControlledVelocityCycle`. **Not present in retail.** | -| `ServerMoveToActive` | PORT | Bridges `MovementType::MoveToObject/Position` (6/7) to per-tick driver. Retail sets equivalent in `MoveToManager`. | -| `LastUmUpdateTime` | HACK | 200 ms grace window on UM authority for player remotes. Workaround for "Shift toggles Run↔Walk without firing a fresh UM" (issue #39). **Not retail.** | -| `MoveToDestinationWorld / MoveToMinDistance / MoveToDistanceToObject / MoveToMoveTowards / HasMoveToDestination / LastMoveToPacketTime` | PORT | Phase L.1c MoveTo state. Used by `RemoteMoveToDriver.Drive`. | -| `TargetOrientation` | DEAD | Comment: "legacy field — no longer used for slerp". Should delete. | -| `ObservedOmega` | HACK | Per-tick rotation rate seeded from `(π/2)×TurnSpeed` formula in `OnLiveMotionUpdated`. Bypasses `PhysicsBody.update_object`'s 30 Hz quantum gate. **Necessary because `update_object` skips most 60 Hz frames** — a real port problem. | -| `CellId` | PORT | High 16 bits = LBxLBy; fed into `ResolveWithTransition`. | -| `Airborne` | HACK | Set by `OnLiveVectorUpdated` when launch velocity has +Z>0.5; cleared by post-resolve `IsOnGround && Vel.Z<=0`. Retail tracks airborne via `Contact|OnWalkable` transient bits + Gravity flag. We carry an extra bool because we toggle Gravity manually. | -| `InterpolationManager Interp` | PORT | Owned per-remote. Only consumed when `ACDREAM_INTERP_MANAGER=1`. | -| `PositionManager Position` | PORT | Same. | -| `LastServerZ` | HACK | Landing-fallback floor for env-var path (gravity drift recovery). Not retail. | -| `PrevServerPos / *Time / Last*LogTime / MaxSeqSpeedSinceLastUP` | DIAG | All gated on `ACDREAM_REMOTE_VEL_DIAG=1`. | - -**Code-health note:** the struct has **31 public fields** spanning physics -state, server-snapshot cache, MoveTo path, diagnostic throttles, and -landing-fallback metadata. About half are workarounds for problems the -retail port doesn't have once we follow the spec; the L.3 refactor should -be able to remove `ServerVelocity/HasServerVelocity`, `LastUmUpdateTime`, -`LastServerZ`, `PrevServerPos*`, `Max*`, and `TargetOrientation`. - ---- - -## 2. `GameWindow.OnLiveMotionUpdated` — L2591–3214 - -Inbound `0xF74C UpdateMotion` handler. Receives an `EntityMotionUpdate` -with stance + ForwardCommand + ForwardSpeed + SideStepCommand + TurnCommand -+ optional MoveTo path payload. Roughly 600 lines. - -**Reads:** `update.MotionState` fields, `ae.Sequencer.{CurrentStyle, -CurrentMotion}`, `_remoteDeadReckon[guid]`, `_animatedEntities`, env vars. - -**Writes:** sequencer via `SetCycle` and `RouteFullCommand`/`RouteWireCommand`; -`rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed}` (bulk-copy); -`rm.ServerMoveToActive`; `rm.LastUmUpdateTime`; `rm.MoveToDestination*`; -`rm.HasMoveToDestination`; `rm.ObservedOmega` (formula seed); also calls -`rm.Motion.DoInterpretedMotion`/`StopInterpretedMotion` for sidestep/turn axes. - -### Verdict per sub-block - -| Sub-block | L# | Verdict | Notes | -|---|---|---|---| -| Diag `[UM_RAW]` and `ACDREAM_DUMP_MOTION` blocks | 2616–2646 | DIAG | Throw-away. | -| Player-only RunRate echo via `ApplyServerRunRate` | 2649–2656 | PORT | Local player only. Out-of-scope for remote audit. | -| Style preservation when `stance==0` | 2667–2671 | PORT | Retail bulk-copy semantics confirmed by named decomp. | -| Stop signal: `command absent OR command.Value==0 → Ready` | 2685–2707 | PORT | Retail `FUN_0051F260` bulk-copy of Invalid. | -| MoveTo seed via `PlanMoveToStart(...)` | 2687–2703 | PORT | Wraps `ServerControlledLocomotion`; aligns with retail `MoveToManager::BeginMoveForward`. | -| Skip-self block at 2757 (don't echo SetCycle for local player) | 2757–2761 | PORT | Local UM is authoritative on the local sequencer. | -| Action/Modifier/ChatEmote overlay route | 2764–2767, 2896–2906 | PORT | `AnimationCommandRouter.Classify`. | -| InterpretedState bulk-copy of `ForwardCommand/ForwardSpeed` for ALL packets (including overlay) | 2842–2868 | PORT | Mirrors retail `copy_movement_from` (`acclient_2013_pseudo_c.txt:293301`). Speed sign preserved. | -| MoveTo path capture | 2870–2893 | PORT | World-converts `OriginX/Y/Z`. | -| Cycle picker: forward → sidestep → turn → Ready priority | 2918–2953 | HACK-ish | Retail's `apply_current_movement` doesn't pick cycles like this; the SUB_STATE animation choice in retail is driven by the wire's ForwardCommand directly. Acdream's picker exists because we synthesise locomotion velocity from the cycle — we need a cycle to play even when only Sidestep or Turn axes are populated. **Re-evaluate during port**. | -| Skip-cycle-swap when airborne (K-fix17) | 2966 | HACK | Workaround for ACE broadcasting UMs mid-arc that would otherwise stomp Falling. Retail handles this via the substate priority; we hack around it. | -| Cycle-fallback chain RunForward → WalkForward → Ready | 2989–3027 | HACK | Defensive against MotionTables missing the requested cycle. Retail behavior unknown — likely a port artifact from MoveTo always seeding RunForward. Could be removed if upstream cycle picker matches retail substate. | -| `DoInterpretedMotion(side/turn axis)` + `StopInterpretedMotion` for absent axes | 3066–3127 | PORT | Stops are explicit; mirrors retail StopMotion semantics. | -| `ObservedOmega` formula seed: `(π/2)×TurnSpeed` signed | 3110–3127 | HACK | Compensates for `update_object` MinQuantum 30 Hz gate. Comment at 6610 explicitly notes this is to bypass that gate. **A real retail port wouldn't need this** — it would call `UpdatePhysicsInternal` directly OR fix the `update_object` substepping. | -| `Commands[]` list iteration, **skipping SubState entries** | 3182–3213 | PORT-with-FIX | 2026-05-03 fix: ACE bundles a Ready into the Commands[] list of a RunForward UM, which our router used to re-cycle to Ready right after we set RunForward. Skipping SubState class entries restored the cycle. | -| `enteringLocomotion` timestamp refresh | 3142–3160 | HACK | Stop-detection timer reset; the legacy stop-detection loop has been removed but this remnant still pokes `_remoteLastMove` and `LastServerPosTime`. Should be removable. | -| Legacy non-sequencer path | 3217–3236 | DEAD-ish | All player remotes have a sequencer — only fired for entities without MotionTable (rare). | - -**Critical observation:** every UM unconditionally bulk-copies into -`InterpretedState.{ForwardCommand,ForwardSpeed}`. That's correct vs. retail. -But it's the bulk-copy that arms `apply_current_movement` to write -`body.Velocity = RunAnimSpeed × ForwardSpeed`. Per the L.3 spec, **for -remote players body.Velocity should always be 0** — meaning `apply_current_movement` -must NOT be called per tick on remote bodies. The legacy path at L6599 -calls it. The env-var path at L6174 explicitly does NOT (and clears -`body.Velocity` to zero each tick at L6205). The two paths are -philosophically opposed. - ---- - -## 3. `GameWindow.OnLiveVectorUpdated` — L3259–3317 - -Inbound `0xF74E VectorUpdate` handler (jump/launch). - -**Reads:** `update.{Velocity,Omega}`, `_remoteDeadReckon`, -`_entitiesByServerGuid`, `_animatedEntities`. - -**Writes:** `rm.Body.{Velocity,Omega}`, `rm.Body.TransientState` (clears -Contact+OnWalkable), `rm.Body.State` (sets Gravity), `rm.Airborne=true`, -`ae.Sequencer.SetCycle(Falling, skipTransitionLink:true)`. - -**Verdict: PORT.** Mirrors retail `SmartBox::DoVectorUpdate` -(@0x004521C0). Sets velocity AND omega, K-fix9 marks airborne, K-fix10 -swaps Falling cycle, K-fix18 skips link. Threshold `Velocity.Z>0.5` to -gate Airborne is acdream-original but harmless. - -**Skips:** local player guid (`_playerServerGuid`). - ---- - -## 4. `GameWindow.ApplyServerControlledVelocityCycle` — L3325–3423 - -Helper that classifies a server-derived velocity into a Walk/Run/Ready -cycle and writes it to both the sequencer (visible cycle) and -`InterpretedState` (body velocity feed). - -**Reads:** `rm.{Airborne,LastUmUpdateTime,ServerMoveToActive}`, -`ae.Sequencer.{CurrentMotion,CurrentStyle,CurrentSpeedMod}`, env vars. - -**Writes:** `ae.Sequencer.SetCycle(...)`, -`rm.Motion.InterpretedState.{ForwardCommand,ForwardSpeed}`. - -**Verdict: HACK.** This whole function exists because: - -1. ACE doesn't broadcast HasVelocity on player UPs. -2. Retail clients don't broadcast a fresh UM on Shift-toggle Run↔Walk. - -So we infer cycle from synthesised position-delta velocity. The 200 ms UM -grace + `IsPlayerGuid` gate are workarounds for ACE-vs-retail timing -asymmetries. **None of this exists in retail.** Issue #39. - -The `IsPlayerGuid(serverGuid)` check at L3349 is one of three places we -use the player-vs-NPC distinction to gate behavior — see §10. - ---- - -## 5. `GameWindow.OnLivePositionUpdated` — L3425–3824 - -Inbound `0xF748 UpdatePosition` handler. Roughly 400 lines, with TWO -distinct code paths (env-var ON vs OFF). - -**Reads:** `update.{Guid,Position,IsGrounded,Velocity}`, -`_entitiesByServerGuid`, `_remoteDeadReckon`, `_liveCenterX/Y`, -`_playerController.{State,Position,StepUpHeight}`, `_physicsEngine`. - -**Writes:** `entity.{Position,Rotation}`, `rmState.Body.{Position, -Velocity,Orientation,LastUpdateTime,TransientState,State}`, -`rmState.{Airborne,CellId,LastServerPos,LastServerPosTime,LastServerZ, -ServerVelocity,HasServerVelocity,TargetOrientation,Interp}`. - -### Common prologue (L3432–3464) - -- World-converts the LB-local position via `(lbX-_liveCenterX)*192`. -- Snaps `entity.Position`/`entity.Rotation` to server truth UNCONDITIONALLY. -- Updates `_physicsEngine.ShadowObjects` for non-self guids. - -**Verdict: PORT** for the world conversion + entity snap. The -`ShadowObjects.UpdatePosition` mirrors retail `change_cell` / -`AddShadowObject`. - -### Env-var ON branch (L3512–3626) - -Only runs when `ACDREAM_INTERP_MANAGER=1`. - -- Hard-snaps `Body.Orientation = rot` (L3516). -- Tracks `LastServerZ` only for grounded UPs (L3529). -- Diagnostic VEL_DIAG block (L3537–3562). -- **AIRBORNE NO-OP** at L3570: `if (!IsGrounded) return;` — mirrors retail - `MoveOrTeleport` "no-op when has_contact==0" branch. -- **LANDING TRANSITION** at L3577: clears Airborne, restores ground - flags, hard-snaps `Body.Position = worldPos`, clears `Interp` queue, - resets sequencer cycle out of Falling. -- **GROUNDED ROUTING** at L3605: distance check `dist > 96f` → - `SetPositionSimple`-style snap (clear + write Body.Position); - otherwise enqueue waypoint via `Interp.Enqueue(...)`. - -**Verdict: PORT** of `CPhysicsObj::MoveOrTeleport` (acclient @0x00516330) -**but with one regression**: the env-var per-tick path (§7) drops the -collision sweep — see ISSUES.md #40. The OnLivePositionUpdated side here -is correct; the regression is in TickAnimations. - -### Env-var OFF branch (L3628–3761) — THE LEGACY PATH - -- Synthesises `serverVelocity = (worldPos - rmState.LastServerPos)/dt` - for ALL remotes when `update.Velocity` is null (L3634–3639). -- Sets `rmState.Body.Position = worldPos` UNCONDITIONALLY (L3650). -- Hard-snaps `rmState.Body.Orientation = rot` (L3686). -- Adopts `update.Velocity` if present, falls back to synth velocity for - NPCs (L3705–3730). -- HasVelocity<0.2 m/s magnitude → `StopCompletely` + sequencer Ready - (L3712–3725). **Verdict: PORT.** -- Calls `ApplyServerControlledVelocityCycle` for player remotes too - (L3737–3757). **Verdict: HACK** (issue #39). -- Final `entity.Position = rmState.Body.Position` snap at L3759. - -**Verdict overall: HACK.** The synth-velocity machinery is the -acdream-only solution to ACE's wire shape. Retail does NOT do this; retail -does pure waypoint-queue interpolation (the env-var branch). - ---- - -## 6. `GameWindow.TickAnimations` — env-var path (L6118–6445) - -Per-frame remote-motion tick when `ACDREAM_INTERP_MANAGER=1`. **Marked -DO-NOT-ENABLE** per ISSUES.md #40. - -**dt source:** `OnRender` passes `(float)deltaSeconds` from Silk.NET's -`OnRender(double deltaSeconds)` callback (L5610). **This is the -render-frame dt — variable, not stable. Typically ~16 ms at 60 Hz, but -spikes during landblock loads, GC, stalls.** - -### Step-by-step - -| Step | Verdict | Notes | -|---|---|---| -| 1. Force `Contact+OnWalkable+Active` for grounded; clear `body.Velocity = 0` (L6194–6206) | PORT | "Body Velocity should be 0 for grounded remotes" — exactly the L.3 spec invariant. | -| 2. `PositionManager.ComputeOffset(...)` returns either queue catch-up OR animation root motion; `body.Position += offset` (L6225–6232) | PORT | Mirrors `CPhysicsObj::UpdatePositionInternal` + `InterpolationManager::adjust_offset`. The REPLACE semantics in `PositionManager.ComputeOffset` match retail. | -| 2.5. Apply `ObservedOmega` (or seqOmega) via manual `Quaternion.Concatenate` (L6247–6277) | HACK | Manual integration to bypass MinQuantum gate. | -| 3. `body.calc_acceleration()` (L6283) | PORT | Retail `FUN_00511420`. | -| 4. `body.UpdatePhysicsInternal(dt)` (L6286) | PORT | Retail `FUN_005111D0`. With `body.Velocity=0` set in step 1, this is a no-op for grounded remotes — only airborne picks up gravity. | -| 4b. `_physicsEngine.ResolveWithTransition(preIntegrate, postIntegrate, ...)` (L6288–6373) | PORT | Added by Commit B (039149a) to fix the missing-collision regression. Mirrors retail `FUN_005148A0`. Sphere dims 0.48 m radius / 1.2 m height / 0.4 m step-up/down. | -| 5. Landing fallback: if airborne and `Body.Z < LastServerZ - 0.5`, force-land (L6387–6421) | HACK | Defensive against ACE not sending IsGrounded promptly. | -| 6. `MaxSeqSpeedSinceLastUP` diag (L6432–6441) | DIAG | Tracks max body-velocity magnitude for the VEL_DIAG ratio. | -| Final: `ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation` (L6443–6444) | PORT | The renderable consumes body state. | - -**Why marked broken:** ISSUES.md #40 says the staircase-on-slope and -position-blip bugs persist even with Commit B's collision sweep ported in. -Comment at L6131–6141 acknowledges that body.Velocity=0 means -pre/postIntegrate sweep input is just queue catch-up, which itself snaps -in 1 Hz UP-cadence steps. - ---- - -## 7. `GameWindow.TickAnimations` — legacy default path (L6446–6764) - -Per-frame remote-motion tick when env var is unset. **The CURRENT -default in production builds.** - -### Step-by-step - -| Step | Verdict | Notes | -|---|---|---| -| 1. Force grounded transient flags (L6488–6492) | HACK | Stomps any airborne flag fluctuation; "remotes are server-authoritative". | -| 1a. NPC HasServerVelocity branch: stale-after-X seconds → zero + cycle to Ready; otherwise `body.Velocity = ServerVelocity` (L6493–6511) | HACK | Synth-velocity continuation. | -| 1b. NPC ServerMoveToActive branch with destination: `RemoteMoveToDriver.Drive` + `apply_current_movement` + `ClampApproachVelocity` (L6512–6587) | PORT | Phase L.1c MoveTo per-tick steering. | -| 1c. `ServerMoveToActive` without destination → `body.Velocity = 0` (L6588–6596) | PORT | Conservative hold. | -| 1d. ELSE branch (everything else, including ALL player remotes): `rm.Motion.apply_current_movement(...)` (L6599) | **BROKEN per L.3 spec** | This is the bug. `apply_current_movement` reads `InterpretedState.ForwardCommand=RunForward + ForwardSpeed` and writes `body.Velocity = RunAnimSpeed × ForwardSpeed × orientation`. Per L.3 the player remote body should NEVER have non-zero velocity from this path; velocity should come solely from animation root motion + interpolation queue catch-up. | -| 2. Manual omega integration via `ObservedOmega` (L6622–6631) | HACK | Same MinQuantum bypass as env-var path. | -| 3. `body.calc_acceleration()` + `body.UpdatePhysicsInternal(dt)` (L6651–6653) | PORT | But because of 1d, body.Velocity is non-zero, so this **double-integrates** — the velocity drives translation here, then `ResolveWithTransition` resolves the Δ. | -| 4. `_physicsEngine.ResolveWithTransition(...)` (L6674–6760) | PORT | Same call as env-var path. | -| 4b. K-fix15 post-resolve landing (L6717–6759) | HACK | Same purpose as env-var step 5. | -| Final: `ae.Entity.Position = rm.Body.Position` (L6762) | PORT | | - -**This is the path the user sees in production.** The `apply_current_movement` -call on every tick at L6599 is **the central thing that the L.3 port has -to remove for player remotes.** Then we replace the per-tick translation -source with animation-root-motion + Interpolation-queue catch-up -(`PositionManager.ComputeOffset` from the env-var path) — but this time -WITH the collision sweep retained. - ---- - -## 8. `MotionInterpreter` (full file, 1023 LOC) - -**Verdict: PORT.** All key methods cite retail addresses: - -- `PerformMovement` (FUN_00529a90), `DoMotion` (FUN_00529930), - `DoInterpretedMotion`, `StopMotion`, `StopInterpretedMotion`, - `StopCompletely` (FUN_00528a50), `get_state_velocity` (FUN_00528960), - `apply_current_movement` (FUN_00529210), `jump` (FUN_00529390), - `get_jump_v_z`, `get_leave_ground_velocity`, `jump_is_allowed`, - `contact_allows_move`, `LeaveGround`, `HitGround`, `GetMaxSpeed` - (CMotionInterp::get_max_speed @0x00527cb0). - -`apply_current_movement` (L653–673) gates on `PhysicsObj.OnWalkable` -and calls `set_local_velocity(get_state_velocity())`. This is the -**single function that translates `InterpretedState` into -`body.Velocity`**. Per the L.3 spec, **this must NOT be called per tick on -remote players' bodies** — only the local player. - -`GetMaxSpeed()` (L972–985) returns `RunAnimSpeed × runRate` (≈ 11.76 m/s -for run-skill 200). This is the value passed to -`InterpolationManager.AdjustOffset` as the catch-up speed cap. - -Question 1 answer: **`body.Velocity` is currently NON-ZERO for player -remotes in the legacy default path**, set every tick by -`apply_current_movement` at L6599. Per L.3 spec it should always be 0. -This is the regression to fix. - -Question 2 answer: `apply_current_movement` is called from: - -- `PlayerMovementController.cs:273` (local player — correct). -- `GameWindow.cs:6567` (NPC MoveTo steering — correct). -- `GameWindow.cs:6599` (legacy default per-tick for ALL non-MoveTo - remotes — **incorrect per L.3 spec**). -- `MotionInterpreter` internally inside `DoInterpretedMotion` / - `StopInterpretedMotion` / `HitGround` (called from - `OnLiveMotionUpdated`'s sidestep+turn axis loops). - -It produces: `body.Velocity = world_rotation × Vector3(SidestepAnimSpeed×SideStepSpeed, -WalkOrRunAnimSpeed×ForwardSpeed, 0)`, clamped to `RunAnimSpeed × runRate`. - ---- - -## 9. `AnimationSequencer` (relevant fields, 1455 LOC total) - -**Verdict: PORT** of retail Sequence. Relevant API surface for the -remote-motion port: - -- `CurrentVelocity` (L246) — sequence-wide latest MotionData.Velocity × - speedMod, body-local. **Synthesised** for known locomotion cycles - (Walk/Run/SideStep) at L614–646 because the Humanoid MotionTable - ships HasVelocity=0 on those cycles. Synthesised values match retail - constants (RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25). -- `CurrentMotion` / `CurrentStyle` / `CurrentSpeedMod` — sequencer's - active cycle. Read by everywhere. -- `SetCycle(style, motion, speedMod, skipTransitionLink)` — has fast-path - for identical motion (sign-matched) → `MultiplyCyclicFramerate`. Cycle - sign-flip path (e.g. positive→negative speed) takes the full rebuild - branch. **PORT**. -- `MultiplyCyclicFramerate(factor)` — scales every cyclic node's framerate - AND scales `CurrentVelocity *= factor`, `CurrentOmega *= factor`. Mirrors - retail `multiply_cyclic_animation_framerate`. **PORT**. -- `CurrentOmega` — synthesised from TurnRight/TurnLeft motion (`±π/2 × - speedMod`) when MotionData.Omega is silent. **PORT.** - -`CurrentVelocity` is **the canonical source for per-tick remote -translation** under the L.3 design. Already wired to -`MotionInterpreter.GetCycleVelocity` for the local player path; for -remotes, `PositionManager.ComputeOffset` reads it directly. - ---- - -## 10. `PhysicsBody` (full file, 436 LOC) - -**Verdict: PORT.** Every method cites a retail FUN address. - -Key fields used by the per-tick remote path: -- `Position` — written by `OnLivePositionUpdated` (hard-snap) AND by - per-tick `Position += offset` in env-var path AND by `UpdatePhysicsInternal`'s - `Position += Velocity*dt + 0.5*Accel*dt²` AND by `ResolveWithTransition`. -- `Velocity` — written by `OnLiveVectorUpdated` (jump launch), - `OnLivePositionUpdated` (HasVelocity adoption), `apply_current_movement` - via `set_local_velocity`, post-resolve landing (zero), and the env-var - per-tick path (forced to 0 each tick). -- `Orientation` — hard-snap on UP, manual integration via ObservedOmega - in both per-tick paths. -- `update_object` — has a `MinQuantum=1/30s` early-return that **silently - skips integration on 60 Hz frames**. This is why we call - `UpdatePhysicsInternal` directly from per-tick paths and do manual omega - integration. The retail intent was sub-stepping for variable dt; our - 60 Hz tick happens to fall under the gate. - -Question 3 answer (`body.Position` write sites): see §6 list. Counted -21 distinct write sites in `GameWindow.cs` alone. The L.3 port should -collapse this to a single canonical write per tick + a single hard-snap -write in OnLivePositionUpdated. - -Question 4 answer (per-tick render dt): L5610 — passed straight from -Silk.NET `OnRender(double deltaSeconds)`. **It is render-frame-rate- -dependent and not stable.** This is a known issue: any code that uses -this dt to integrate motion (e.g. `body.UpdatePhysicsInternal(dt)`, -`PositionManager.ComputeOffset(dt, ...)`, manual omega step) is -implicitly tied to render rate. Retail's `update_object` clamps dt to -[MinQuantum, MaxQuantum=0.1, HugeQuantum=2.0] and sub-steps; we bypass -that gate per §10. - ---- - -## 11. `InterpolationManager` (full file, 329 LOC) - -**Verdict: PORT.** Retail `InterpolationManager::adjust_offset` -(@0x00555D30) + UseTime stall/blip (@0x00555F20). Constants verified -from named binary. Only consumed when `ACDREAM_INTERP_MANAGER=1` (i.e. -the env-var path). - -API surface used: -- `Enqueue(targetPosition, heading, isMovingTo)` — called from - `OnLivePositionUpdated` env-var path L3623. -- `AdjustOffset(dt, currentBodyPosition, maxSpeedFromMinterp)` — - called from `PositionManager.ComputeOffset`. -- `Clear()` — on landing, on far-snap, on UP after stale. -- `IsActive` — diagnostic. - -Stall detection (5-frame window, `progress_quantum`, `node_fail_counter`) -is faithfully ported. **This whole machinery is currently unused in the -default build** — it's the env-var path's centerpiece. - ---- - -## 12. `PositionManager` (76 LOC) - -**Verdict: PORT.** Retail `CPhysicsObj::UpdatePositionInternal` -(@0x00512c30) + `InterpolationManager::adjust_offset` (@0x00555D30). - -`ComputeOffset(dt, currentBodyPosition, seqVel, ori, interp, maxSpeed)`: -1. Calls `interp.AdjustOffset(...)` for the queue catch-up vector. -2. If catch-up is non-zero (queue active and body far from head), - **returns the catch-up directly** — REPLACES the offset, doesn't add. -3. If catch-up is zero (queue empty or body within DesiredDistance), - **returns animation root motion**: `Vector3.Transform(seqVel * dt, - ori)`. - -**This is the canonical L.3 per-tick offset.** Currently only consumed -in the env-var path. - ---- - -## 13. `ServerControlledLocomotion` (129 LOC) - -**Verdict: PORT-ish + HACK.** Two functions: - -- `PlanMoveToStart(moveToSpeed, runRate, canRun)` — seeds RunForward (or - WalkForward if !canRun) for an inbound MoveTo packet. Retail - `MoveToManager::BeginMoveForward` + `MovementParameters::get_command`. - **PORT.** -- `PlanFromVelocity(worldVelocity, currentMotion)` — classifies a - velocity into Ready/Walk/Run with hysteresis bands (Walk→Run at 3.90, - Run→Walk at 3.43). **HACK** — this is the workaround for "ACE rarely - sets HasVelocity on player UPs." Retail doesn't classify like this; it - just plays the cycle the wire told it to play. - ---- - -## 14. `RemoteMoveToDriver` (304 LOC) - -**Verdict: PORT.** Retail `MoveToManager::HandleMoveToPosition` -(@0x00529d80). Steers body orientation toward destination, fires -arrival predicate, ports the ±20° HeadingSnapToleranceRad fudge. Used -only for NPC MoveTo packets — not on the player-remote path. Out of -scope for this audit's primary concern but listed because the spec -asked. - -`ClampApproachVelocity` (L260–293) is acdream-original belt-and-braces -to prevent overshoot in the final tick. **HACK** but harmless. - ---- - -## Specific question answers (§§ refs in §1–§14) - -1. **Is `body.Velocity` ever non-zero for player remotes?** - Yes, every tick of the legacy default path (L6599 - `apply_current_movement`) writes `body.Velocity = RunAnimSpeed×ForwardSpeed` - in world frame. Also briefly during jumps (`OnLiveVectorUpdated`) and - during stop UPs with `HasVelocity~0`. Per L.3 spec this should be 0 - except during airborne arcs. The legacy path is the regression. - -2. **Where is `apply_current_movement` called?** See §8. Three live - call sites: PlayerMovementController (correct), GameWindow MoveTo - steering (correct), GameWindow legacy per-tick for player remotes - (wrong per L.3). It produces body-local - `(SidestepAnimSpeed×SideStepSpeed, RunAnimSpeed×ForwardSpeed, 0)` - then rotates by orientation. - -3. **Where is `body.Position` written?** 21 sites in `GameWindow.cs`: - 2 in `OnLiveVectorUpdated`-adjacent code (none direct on Position), - ~5 in `OnLivePositionUpdated` (hard-snap), ~14 in `TickAnimations` - (env-var: 2 direct + 1 via Resolve; legacy: 2 + 1 via Resolve; - plus airborne/landing fallbacks). See L3585, L3614, L3650, L6232, - L6332, L6397, L6699 + the `ae.Entity.Position = rm.Body.Position` - mirrors at L3759, L6443, L6762. - -4. **Per-tick render dt source?** Silk.NET `OnRender(double deltaSeconds)` - → `TickAnimations((float)deltaSeconds)` at L5610. **Variable; tied to - render rate; no clamp before reaching `UpdatePhysicsInternal` / - `ComputeOffset`.** `PhysicsBody.update_object` would clamp this if - used, but we bypass it via direct `UpdatePhysicsInternal` calls. - -5. **Env-var vs legacy default relationship?** Two parallel per-tick - implementations forked at L6118 in `TickAnimations`. Env-var path - (L6118–6445) clears `body.Velocity=0` each tick and translates via - `PositionManager.ComputeOffset` (anim root motion OR queue catch-up). - Legacy path (L6446–6764) calls `apply_current_movement` to write - `body.Velocity` from InterpretedState then integrates via - `UpdatePhysicsInternal`. They have parallel `ResolveWithTransition` - collision sweeps (env-var added in Commit B as a regression fix). - The env-var path is the L.3 architecture but is currently REGRESSED - (issue #40 — staircase + blips). The legacy path is the production - default but is fundamentally wrong vs L.3 spec. - -6. **What does `entity.Position` (renderable) read from?** `body.Position` - only. Final assignment at L6443 (env-var) or L6762 (legacy) per tick; - hard-snap to server `worldPos` in `OnLivePositionUpdated` (L3451 then - over-written to `rmState.Body.Position` at L3759). - -7. **`IsPlayerGuid` gate sites:** L706 (definition), L3349 - (`ApplyServerControlledVelocityCycle` UM-grace branch), L3727 - (`OnLivePositionUpdated` velocity-adoption fallback), L6493/L6512/L6588 - (legacy per-tick branch selection between NPC paths and the catch-all - `apply_current_movement` else-branch). **All five are guards that route - player remotes through the broken `apply_current_movement` path.** A - port that drops the special player-vs-NPC distinction at the per-tick - layer would invert all five. - ---- - -# Summary - -## (a) Code health for player remote motion - -**Mixed-to-poor.** The retail-port primitives — `MotionInterpreter`, -`AnimationSequencer`, `PhysicsBody`, `InterpolationManager`, -`PositionManager`, and the inbound packet handlers' bulk-copy semantics -— are individually faithful and well-cited. But the per-tick integration -in `GameWindow.TickAnimations` has forked into two parallel paths -(env-var-gated `ACDREAM_INTERP_MANAGER=1` and legacy default), neither -of which currently ships the right behavior. The legacy default calls -`apply_current_movement` every tick on player remotes — directly violating -the L.3 invariant that body.Velocity should be 0 for grounded remotes — -and the env-var path drops too much (no body integration, body.Velocity -forced to 0 even mid-jump-arc until the recent Commit B partial-fix). Worse, -`OnLiveMotionUpdated` carries ~600 lines of cycle-picker logic, missing- -cycle-fallback chains, ObservedOmega formula seeding, and `IsPlayerGuid`- -gated workarounds that compensate for ACE wire shape vs. retail. The -`RemoteMotion` struct has 31 fields, half of which are workaround state -that the retail port shouldn't need. - -## (b) Top 3 things that need to change - -1. **Remove `apply_current_movement` from the per-tick remote path - entirely.** Replace with `PositionManager.ComputeOffset(dt, body.Position, - sequencer.CurrentVelocity, body.Orientation, interp, GetMaxSpeed())` - — the env-var path's translation source — but keep the - `ResolveWithTransition` collision sweep that the legacy path correctly - includes. `body.Velocity` stays at 0 except during airborne arcs. -2. **Collapse the env-var branch into the default and delete legacy.** - The fork is the bug; both paths should converge on the L.3 design with - `InterpolationManager` queue + animation root motion + collision sweep. - Remove `_remoteDeadReckon`'s `ServerVelocity / HasServerVelocity / - LastUmUpdateTime / LastServerZ / PrevServerPos*` workaround fields. -3. **Drop the `IsPlayerGuid` per-tick gate.** Retail runs the same motion - pipeline for every entity; the special-casing in - `ApplyServerControlledVelocityCycle` (issue #39 hysteresis) and the 3 - per-tick branch sites exist only because we synthesise velocity from - position deltas. Once the per-tick translation is anim-root-motion - driven, players and NPCs share one path and the gates can be inverted - or removed. - -## (c) Path to written audit - -`docs/research/2026-05-04-l3-port/06-acdream-audit.md` (this file). diff --git a/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md b/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md deleted file mode 100644 index 4ef12fc2..00000000 --- a/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md +++ /dev/null @@ -1,919 +0,0 @@ -# L.3 port research — StickyManager / ConstraintManager / MoveToManager - -**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp). -**Cross-refs**: `references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,ConstraintManager,MoveToManager}.cs`, -`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, -acdream `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`. - -All retail line numbers below refer to that file. - ---- - -## 1. StickyManager — "follow object at fixed radius" - -### Purpose - -`StickyManager` is the **post-arrival follow-on** for `MoveToObject`. After -`BeginNextNode` exhausts the pending action list and finds -`movement_params.__inner0` had the high bit set (sticky-after-arrive flag), -it calls `PositionManager::StickTo(top_level_object_id, radius, height)` -(line 307159). From that point, every physics tick `PositionManager::adjust_offset` -calls `StickyManager::adjust_offset` to nudge the body's per-tick position -delta toward the target, maintaining a fixed cylindrical separation. - -### State (`sizeof = 0x60`, lines 352620-352633 ctor + 0x796910 vtable) - -| Offset | Field | Notes | -|---|---|---| -| 0x00 | `target_id` | uint32. 0 means "not stuck". | -| 0x04 | `physics_obj` | back-pointer to `CPhysicsObj`. | -| 0x08 | `target_position.vtable` | `0x796910` = `class Position`. | -| 0x0c | `target_position.objcell_id` | last known target cell. | -| 0x10..0x4c | `target_position.frame` | qw/qx/qy/qz/origin (cached). | -| 0x4c | `target_radius` | float. From `StickTo arg3`. | -| 0x50 | `initialized` | int32. 1 after first `HandleUpdateTarget Ok`. | -| 0x54..0x5c | `sticky_timeout_time` | double, line 352576 = `cur_time + 1.0`. | - -### `StickyManager::Create(CPhysicsObj*)` @ 0x00555800 - -```c -00555804 void* result = operator new(0x60); -00555814 *(uint32_t*)result = 0; // target_id -00555816 *(uint32_t*)((char*)result + 4) = 0; // physics_obj (set later by SetPhysicsObject) -0055581c *(uint32_t*)((char*)result + 8) = 0x796910; // Position vtable -00555826 *(uint32_t*)((char*)result + 0x10) = 0x3f800000; // qw = 1.0f -... zero everything else -0055582f *(uint32_t*)((char*)result + 0x18) = 0; -``` - -Followed (via `PositionManager::Create` line 352252) by `SetPhysicsObject`. - -### `StickyManager::StickTo(target_id, radius, height)` @ 0x00555710 (lines 352559-352578) - -Plain language: "from now on, follow `target_id` at radius `radius`, and -notify the engine to start tracking that target." - -```c -00555716 if (this->target_id != 0) { // already stuck → unstick first -00555718 class CPhysicsObj* physics_obj = this->physics_obj; -0055571b this->target_id = 0; -00555721 this->initialized = 0; -00555728 CPhysicsObj::clear_target(physics_obj); -00555730 CPhysicsObj::interrupt_current_movement(this->physics_obj); -} -00555749 this->target_radius = arg3; // arg3 = radius -0055574f this->target_id = arg2; // arg2 = object id -00555751 this->sticky_timeout_time = Timer::cur_time + 1.0; // 1-second alive window -0055575a this->initialized = 0; -00555771 CPhysicsObj::set_target(physics_obj, 0, arg2, 0.5f, 0.5f); -``` - -`arg4` (target_height) is **received but not stored** — sticky uses cylinder -distance (no Z), so height is irrelevant. The 0.5f/0.5f passed to `set_target` -is the target tracking radius/height the server-update path uses to filter -which `TargetInfo` updates land here. - -### `StickyManager::HandleUpdateTarget(TargetInfo)` @ 0x00555780 (lines 352582-352607) - -Server-driven: when `CPhysicsObj` receives a fresh tracked-target -position update, this absorbs it. - -```c -00555789 if (arg2.object_id == target_id) { - if (arg2.status == Ok_TargetStatus) { - this->initialized = 1; - this->target_position.objcell_id = arg2.target_position.objcell_id; - Frame::operator=(&this->target_position.frame, &arg2.target_position.frame); - return; - } - if (target_id != 0) // status != Ok → bail out - ClearTarget(); // (inlined) -} -``` - -### `StickyManager::adjust_offset(Frame* offset, double quantum)` @ 0x00555430 — **the per-tick steerer** - -This is the function `PositionManager::adjust_offset` calls every tick at -line 005551ba. - -**Branch guard** (line 352356): only runs when `target_id != 0 AND initialized != 0`. - -**Algorithm** in plain language (with retail line cites): - -1. **Compute world-space offset to target** (lines 352358-352366): - - `edi_2 = &physics_obj->m_position` (our position) - - `eax = GetObjectA(target_id)` — get the live target if still in our world - - `ebp_1 = (eax != 0) ? &eax->m_position : &this->target_position` — fall back to last-known position if target despawned - - `Position::get_offset(edi_2, &__return, ebp_1)` writes - `(target.world - me.world)` into `offset.m_fOrigin`. - -2. **Convert to my local space and flatten Z** (lines 352370-352374): - - `Position::globaltolocalvec(...)` rotates the offset by my inverse heading. - - `offset.m_fOrigin.z = 0` — sticky is **always horizontal**. - -3. **Compute cylinder distance minus 0.3 m sticky radius** (lines 352375-352378): - - `target_radius = this->target_radius` - - `var_34_1 = CPhysicsObj::GetRadius(physics_obj)` (own radius — actually unused; computed for side effect or future use) - - `var_14_1 = Position::cylinder_distance_no_z(edi_2, target_radius, ebp_1) - 0.30000001f` - - So `dist = horizontal_separation_minus_combined_radii - 0.3 m`. - -4. **Normalize the offset direction** (lines 352381-352387): - - `if Vector3::normalize_check_small(offset.m_fOrigin) != 0 → offset.m_fOrigin = (0,0,0)` — when target is on top of us, no direction. - -5. **Compute step speed** (lines 352392-352409): - - `eax_7 = CMotionInterp::get_max_speed(get_minterp(physics_obj))` — pull body's max forward speed. - - If `get_minterp == 0` → step skipped (`top_1 = 0`). - - Compare against `F_EPSILON`. If `max_speed < F_EPSILON` → use `MAX_VELOCITY` instead (the global cap). - - **ACE port (line 112)**: `speed = minterp.get_max_speed() * 5.0f` then `if speed < EPSILON → 15.0f`. - The retail `* 5.0f` constant isn't visible in our pseudo-C extract because the FP - stack ops are unimplemented in the BinaryNinja output; ACE's value is the - authoritative interpretation. - -6. **Multiply offset by `min(speed × quantum, dist)`** (lines 352411-352455): - - The branching (`if p_2`) selects between the speed-clamped delta and the - distance-clamped delta. ACE's port (lines 117-121) makes this explicit: - ```csharp - var delta = speed * (float)quantum; - if (delta >= Math.Abs(dist)) delta = dist; - offset.Origin *= delta; - ``` - - Z stays 0; X/Y get scaled to the final per-tick step. - -7. **Set heading toward target** (lines 352456-352492): - - `Position::heading(edi_2, ebp_1)` — compute heading from us to target (degrees, 0..360). - - `Frame::get_heading(&edi_2->frame)` — current heading. - - `delta = target_heading - current_heading`. - - If `|delta| < F_EPSILON` → `delta = 0`. - - If `delta < -F_EPSILON` → `delta += 360.0f`. - - `Frame::set_heading(arg2, delta)` — bake the **rotation delta** (not absolute) into the frame the caller passes in. The caller composes this onto the body next tick. - -### `StickyManager::UseTime` @ 0x00555610 (lines 352498-352517) - -Empty unless `target_id != 0 AND cur_time > sticky_timeout_time`. When timeout -elapses without a `HandleUpdateTarget Ok`, drops the target completely. The -1-second timeout (`sticky_timeout_time = cur_time + 1.0`) means the server has -to keep refreshing the target every second or sticky drops. - -### `StickyManager::UnStick` @ 0x00555400 + `Destroy` @ 0x00555650 + `~StickyManager` @ 0x005557e0 - -`UnStick` and `Destroy` both clear `target_id`, `initialized`, call -`CPhysicsObj::clear_target(physics_obj)`, then **`UnStick` also calls -`CPhysicsObj::interrupt_current_movement`** while `Destroy` does not. The -distinction matters: `UnStick` is a deliberate "stop sticky now"; `Destroy` -is "we're being torn down, don't side-effect into the still-existing physics -state machine." - -### Critical questions answered - -- **When does sticky activate?** Only via `BeginNextNode`'s post-arrival - branch (line 307143-307159) when `movement_params.__inner0` has its high - bit set. Outbound `MoveToObject` packets with that flag are server-side AI - scripts (combat tracking, NPC follow, etc.). Player-driven moves don't - set it. -- **What does it write to the Frame?** `m_fOrigin = (xy_step_toward_target, 0)` - in **local space** (rotated to body-local before being scaled, so when the - body composes this onto its own frame the step lands in the right world-space - direction). Plus `set_heading(rotation_delta)` — the body turns to face the - target each tick at `set_heading` rate (with the same kind of fudge as - ACE's `set_heading(target, true)`). - ---- - -## 2. ConstraintManager — "leash to a fixed point" - -### Purpose - -Force the body to stay within a soft bubble around `constraint_pos`. Used for -quest geometry like "can't leave this room", monster aggro tethers, etc. -Smaller scope than sticky and **purely position-based** (no rotation). - -### State (`sizeof = 0x5c`, lines 353442-353474 ctor) - -| Offset | Field | Notes | -|---|---|---| -| 0x00 | `physics_obj` | back-pointer (set last, line 353473). | -| 0x04 | `is_constrained` | int32. | -| 0x08 | `constraint_pos.vtable` | not 0x796910 here — Position embed begins at +0xc. | -| 0x0c | `constraint_pos.vtable` (real) | `0x796910` = `class Position`. | -| 0x10 | `constraint_pos.objcell_id` | 0 | -| 0x14..0x4c | `constraint_pos.frame` | qw/qx/qy/qz/origin | -| 0x48 | `constraint_distance_start` | float — soft bubble inner radius | -| 0x4c | `constraint_distance_max` | float — hard bubble outer radius | -| 0x50 | `constraint_pos_offset` | float — current distance from `constraint_pos` | - -(ACE's port stores them by C# field names mirroring the above; the offsets aren't -load-bearing, the meanings are.) - -### `ConstraintManager::ConstrainTo(Position* pos, float startDist, float maxDist)` @ 0x00556240 (lines 353528-353537) - -```c -00556248 this->is_constrained = 1; -00556259 this->constraint_pos.objcell_id = arg2->objcell_id; -0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame); -00556271 this->constraint_distance_start = arg3; -00556274 this->constraint_distance_max = arg4; -0055627c this->constraint_pos_offset = Position::distance(arg2, &this->physics_obj->m_position); -``` - -Snapshot the leash anchor + radii + initial offset. - -### `ConstraintManager::adjust_offset(Frame* offset, double quantum)` @ 0x00556180 — **the per-tick clamper** - -```c -00556186 class CPhysicsObj* physics_obj = this->physics_obj; -0055618a if (physics_obj == 0) return; -00556190 if (this->is_constrained == 0) return; - -005561a7 if ((physics_obj->transient_state & 1) != 0) { // bit 0 = "Contact" (touching ground) - if (this->constraint_pos_offset < this->constraint_distance_max) { - if (this->constraint_pos_offset > this->constraint_distance_start) { - // soft zone: scale offset DOWN proportionally to how far into the soft band we are - float scale = (constraint_distance_max - constraint_pos_offset) / - (constraint_distance_max - constraint_distance_start); - Vector3::operator*=(&arg2->m_fOrigin, scale); - } - // else: inside inner bubble, leave offset unchanged - } else { - // hard zone: zero the offset entirely. No movement allowed beyond max. - arg2->m_fOrigin = Vector3::Zero; - } -} -00556233 this->constraint_pos_offset = arg2->m_fOrigin.x + this->constraint_pos_offset; -// ^^ NOTE: the pseudo-C extract reads ".x +", but the actual algorithm uses -// the **length** of m_fOrigin (the magnitude of the per-tick step). ACE's -// port (line 76) confirms: `ConstraintPosOffset = offset.Origin.Length();` -// The Binary Ninja extract garbled the FP stack ops here. -``` - -### Branches & flag bits - -- **`transient_state & 1` = `Contact` flag.** ConstraintManager only fires - when the body is **touching the ground**. Mid-air motion (jumps, falls) - is unaffected. (This is the same `transient_state` bit that motion code - checks to decide whether `kill_velocity` is allowed — see commit a3f53c2.) -- **No rotation**: `set_heading` is never touched. Constraint is purely - positional. -- **No timeout**: `UseTime` is empty. Stays engaged until `Unconstrain` - or `~ConstraintManager`. - -### `IsFullyConstrained` @ 0x005560d0 (lines 353413-353427) - -```c -return constraint_distance_max * 0.9f < constraint_pos_offset; -``` - -"Are we within 10% of the hard limit?" Used by callers to decide whether -to schedule a course correction. - -### Critical questions answered - -- **What kinds of constraints exist?** Only **translation** in the form of a - soft-clamp toward `constraint_pos`. No rotation lock, no cell lock — those - are server-enforced. -- **When does it fire?** Per tick, but only when the body has the Contact - bit (touching ground). Ignored during jumps/falls. - ---- - -## 3. MoveToManager — full state machine for AI/scripted motion - -### Purpose - -Server-side AI's locomotion executor. When the server wants a creature to -"walk to that rock then turn south", it sends a `MovementStruct` (one of 4 -shapes) packed via `MovementParameters::UnPackNet`. `PerformMovement` -unpacks it, picks a top-level branch, the entrypoint queues a list of -**pending nodes** (each either `MoveToPosition` opcode `7` or `TurnToHeading` -opcode `9`), and `UseTime` ticks the head-of-queue node every physics frame -until the queue empties. - -### State (`sizeof = 0x160`, lines 306554-306592 ctor) - -| Field | Type | Notes | -|---|---|---| -| `sought_position` | Position | The original target requested by the server. | -| `current_target_position` | Position | The "right now" target — interpolated for moving objects. | -| `starting_position` | Position | Where the body was when the move began. Used by fail-distance check. | -| `pending_actions` | DLListBase | Doubly-linked list of `{opcode, value}` nodes. Opcode 7 = MoveToPosition, opcode 9 = TurnToHeading (with heading float). | -| `movement_params` | MovementParameters | Packed flags, distances, speeds, hold-key, etc. (Section 4.) | -| `physics_obj`, `weenie_obj` | back-pointers | | -| `movement_type` | enum (`Invalid` or 6=MoveToObject / 7=MoveToPosition / 8=TurnToObject / 9=TurnToHeading) | | -| `current_command`, `aux_command` | uint32 | Active motion-command IDs. `current_command` is the "main" (forward/backward), `aux_command` is the simultaneous turn (e.g., 0x6500000d=TurnLeft, 0x6500000e=TurnRight). | -| `previous_distance`, `previous_distance_time` | float, double | For `CheckProgressMade` (fail detector). | -| `original_distance`, `original_distance_time` | float, double | Initial state for over-1-second progress check. | -| `previous_heading` | float | TurnToHeading's per-tick angle tracker. | -| `fail_progress_count` | int32 | Stalled-tick counter. | -| `sought_object_id`, `top_level_object_id` | uint32 | The object we're chasing (and its outermost parent if attached). | -| `sought_object_radius`, `sought_object_height` | float | Cylinder dimensions for distance test. | -| `moving_away` | int32 | 0=chase, 1=flee. Affects which arrival predicate fires. | -| `initialized` | int32 | 1 once we've gotten the first `HandleUpdateTarget Ok` for a moving target. | - -### Top-level entry: `MoveToManager::PerformMovement(MovementStruct*)` @ 0x0052a900 (lines 307871-307904) - -```c -0052a901 int32_t var_8 = 0x36; // WeenieError code that CancelMoveTo will use -0052a905 CancelMoveTo(this, edx); // wipe any in-flight move -0052a910 CPhysicsObj::unstick_from_object(this->physics_obj); - -0052a923 switch (arg2->type - 6) { - case 0: // MoveToObject (type 6) - MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params); - break; - case 1: // MoveToPosition (type 7) - MoveToPosition(this, &arg2->pos, arg2->params); - break; - case 2: // TurnToObject (type 8) - TurnToObject(this, arg2->object_id, arg2->top_level_id, arg2->params); - break; - case 3: // TurnToHeading (type 9) - TurnToHeading(this, arg2->params); - break; -} -return 0; -``` - -### `MoveToManager::MoveToObject` @ 0x00529680 (lines 306756-306817) - -Stores: `sought_object_id = arg2`, `top_level_object_id = arg3`, -`sought_object_radius = arg4` (cylinder R), `sought_object_height = arg5`. -Copies `arg6` (MovementParameters) field-by-field into `this->movement_params`. -Saves current position into `starting_position`. Sets `movement_type = 6`, -`initialized = 0`. - -If `arg3 == this->physics_obj->id` → it's us; CleanUp and bail. - -Otherwise: `CPhysicsObj::set_target(physics_obj, 0, arg3, 0.5f, 0.0f)` — start -tracking. The actual movement queue isn't built here; it's built by -`HandleUpdateTarget` once the first target snapshot arrives (see below). - -### `MoveToManager::MoveToPosition` @ 0x0052a240 (lines 307521-307593) - -Position is known immediately, so the queue is built directly: - -```c -// Wipe in-flight motion -StopCompletely(physics_obj_1); -// Snapshot target -this->current_target_position = *arg2; -this->sought_object_radius = 0.0f; -GetCurrentDistance(this); // returns |dist| in x87_r0 -// Compute heading delta to target -float curHeading = CPhysicsObj::get_heading(physics_obj_2); -float headingToTarget = Position::heading(&physics_obj_2->m_position, arg2); -float delta = headingToTarget - curHeading; -if (|delta| < EPSILON) delta = 0; -if (delta < -EPSILON) delta += 360.0f; // normalize to [0, 360) - -// Ask MovementParameters which command to issue (RunForward/WalkForward/Backwards/none) -// var_c = command, var_4 = holdKey, var_8 = movingAway -MovementParameters::get_command(arg3, dist, delta, &var_c, &var_4, &var_8); - -// If we need to move at all, queue: TurnToHeading(headingToTarget) → MoveToPosition node -if (var_c != 0) { - float h = Position::heading(&this->physics_obj->m_position, arg2); - AddTurnToHeadingNode(this, h); // opcode 9 - AddMoveToPositionNode(this); // opcode 7 -} - -// If "use final heading" flag (bit 0x40) is set, queue a final TurnToHeading(desired_heading) -if ((arg3->__inner0 & 0x40) != 0) - AddTurnToHeadingNode(this, arg3->desired_heading); - -// Snapshot positions -this->sought_position = *arg2; -this->starting_position = this->physics_obj->m_position; -this->movement_type = 7; -this->movement_params = *arg3; // field-by-field copy -this->movement_params.__inner0 &= 0xffffff7f; // clear bit 7 (sticky-after-arrive flag) — that's only valid for MoveToObject - -BeginNextNode(this); // pop the head and dispatch -``` - -### Pending-action queue ops - -- **`AddMoveToPositionNode`** @ 0x00529580: appends `{opcode=7, value=undefined}`. -- **`AddTurnToHeadingNode(float h)`** @ 0x00529530: appends `{opcode=9, value=h}`. -- **`RemovePendingActionsHead`** @ 0x00529380: pop head, free node. - -### `MoveToManager::BeginNextNode` @ 0x00529cb0 (lines 307123-307171) - -Dispatch loop: - -```c -if (head_ != nullptr) { - int op = head_->opcode; // offset +8 - if (op == 7) tailcall BeginMoveForward(this); - if (op == 9) tailcall BeginTurnToHeading(this); - return; -} - -// Queue empty. -head_ = (uint8_t)this->movement_params.__inner0; // recycle head_ as a register… -if (head_ < 0) { // i.e., bit 7 set on __inner0 == sticky-after-arrive - float radius = this->sought_object_radius; - float height = this->sought_object_height; - uint32_t topId = this->top_level_object_id; - CleanUp(this); - if (physics_obj != 0) StopCompletely(physics_obj); - PositionManager::StickTo(get_position_manager(physics_obj), topId, radius, height); - return; -} - -// Queue empty, no sticky → done. CleanUp + StopCompletely. -CleanUp(this); StopCompletely(physics_obj); -``` - -### `MoveToManager::BeginMoveForward` @ 0x00529a00 (lines 306957-307042) — opcode 7 dispatch - -1. Sanity: physics_obj null → CancelMoveTo with err 8 (NotInitialized). -2. `var_3c = GetCurrentDistance(this)` → distance to current target. -3. Compute heading-to-target delta in `var_40_1`, normalize like in `MoveToPosition`. -4. `MovementParameters::get_command(&this->movement_params, var_3c, var_40_1, &var_38, &var_34, &var_30)` → - `var_38=command`, `var_34=holdKey`, `var_30=movingAway`. -5. **If `command == 0` (already arrived)**: `RemovePendingActionsHead`, then - `BeginNextNode` (advance to next pending node — typically a final TurnToHeading). -6. **Else**: `_DoMotion(this, command, &local_movement_params_clone)`. - On non-zero return → `CancelMoveTo(this, retval)`. - On success: stash `current_command = var_38`, `moving_away = var_30`, - `movement_params.hold_key_to_apply = var_34`, snapshot - `previous_distance/_time` and `original_distance/_time`. - -### `MoveToManager::BeginTurnToHeading` @ 0x00529b90 (lines 307046-307120) — opcode 9 dispatch - -1. Need head_ + physics_obj; otherwise CancelMoveTo err 8. -2. **If motions are pending in the body** (`CPhysicsObj::motions_pending != 0`), - skip — wait for body to settle. -3. Get target heading from `head_->value` (offset +0xc). -4. `st0 = heading_diff(target, current, 0x6500000d)` — 0x6500000d is - TurnLeft, so `heading_diff` returns "how much left-turn from current to - target". -5. **If diff ≈ 180** (within EPSILON of being directly behind): pop head and - advance via `BeginNextNode`. Avoids ambiguous turn direction. -6. **If diff ≈ 0** (within EPSILON of already aligned): pop head and advance. -7. **Else** decide direction: `edi = (st0 > 180) ? 0x6500000e (TurnRight) : 0x6500000d (TurnLeft)`. -8. `_DoMotion(this, edi, &local_params)`. On failure → CancelMoveTo. On - success: `current_command = edi`, `previous_heading = st0`. - -### `MoveToManager::HandleMoveToPosition` @ 0x00529d80 — **the per-tick driver** (lines 307187-307438) - -This is what `RemoteMoveToDriver.cs` is named after. Called every physics -frame from `UseTime` while the head pending node is opcode 7. - -**Plain-language flow:** - -1. **Aux turn correction** (lines 307213-307287). If body has motions pending, - stop the aux turn (we let body finish first). Otherwise: - - Compute `worldHeading = Position::heading(my_pos, current_target_position)`. - - `desiredHeading = worldHeading + MovementParameters::get_desired_heading(current_command, moving_away)` - — `get_desired_heading` returns 0 for forward/180 for backward when chasing, - swapped when fleeing. - - Normalize to `[0, 360)`. - - `delta = desiredHeading - currentHeading`, normalized to `[0, 360)`. - - **If `delta ≤ 20°` OR `delta ≥ 340°`** (within 20° of correct facing): - stop the aux turn (`_StopMotion(aux_command)`, `aux_command = 0`). - - **Else** pick direction: `edi_1 = (delta > 180) ? TurnRight : TurnLeft`. - If different from `aux_command`, `_DoMotion(edi_1, ...)` and - `aux_command = edi_1`. - -2. **Distance check** (line 307289 `GetCurrentDistance` → `var_88_3`). - -3. **Progress check** (line 307294 `CheckProgressMade(this, var_88_3) == 0`): - - **If no progress**: if not interpolating and no motions pending, - `fail_progress_count += 1`. (Body might be wedged on geometry; the - counter is read by external code to decide when to give up.) - - **If progress**: reset `fail_progress_count = 0`. Then check arrival: - - **Chase (`moving_away == 0`)**: arrived when `dist ≤ DistanceToObject` - (line 307323 — `fcomp st0, [esi+0xe4]` is `movement_params.distance_to_object`). - - **Flee (`moving_away == 1`)**: arrived when `dist ≤ MinDistance` - (line 307309 — `[esi+0xe8]`). - - **If arrived**: `RemovePendingActionsHead`, `_StopMotion(current_command)`, - `current_command = 0`, stop aux too, `BeginNextNode`. - - **If past fail_distance**: `dist_from_start = Position::distance(starting_position, my_pos)`, - and if `dist_from_start > fail_distance` → `CancelMoveTo` with err 0x3D - (`ObjectGone` / "fail-distance exceeded"). - -4. **Adaptive quantum tuning** (lines 307376-307437). If we have a tracked - target (`top_level_object_id != 0`): - - `velocity = CPhysicsObj::get_velocity(this->physics_obj)`. - - `speed_sq = vx² + vy² + vz²`. `speed = sqrt(speed_sq)`. - - **If `speed > 0.1`** (line 307400 `0.1` const) — body actually moving: - - `quantum = var_88_3 / speed` — projected time-to-arrival. - - `if |target_quantum - quantum| > 1.0` → - `CPhysicsObj::set_target_quantum(quantum)`. - - This is how the engine speeds up the next physics tick when we're - close to arrival, so we don't overshoot. - -**ACE divergence note**: ACE swaps the chase/flee predicates (uses -`DistanceToObject` for chase arrival vs retail's `min_distance`). The retail -field naming and the physical meaning agree though — the arrival condition -is "distance shrunk past the threshold I asked for". `RemoteMoveToDriver.cs` -already follows retail correctly here (see its line 50-57 doc-comment). - -### `MoveToManager::HandleTurnToHeading` @ 0x0052a0c0 (lines 307442-307517) — opcode 9 driver - -1. If `current_command` isn't TurnLeft/TurnRight, fall through to - `BeginTurnToHeading` (re-pick direction). -2. Get current heading. Test - `heading_greater(curHeading, targetHeading, current_command)` — - "have we passed the target". - - If yes: snap heading via `CPhysicsObj::set_heading(physics_obj_1, target, true)`, - pop pending head, `_StopMotion(current_command)`, `current_command = 0`, - `BeginNextNode`. -3. Else compute `delta_per_tick = heading_diff(curHeading, previous_heading, current_command)`: - - If `delta_per_tick ≥ 180` (turning the wrong way through the long arc): - skip the no-progress increment. - - If `delta_per_tick ≥ 0.0002f`: progress → `fail_progress_count = 0`, - `previous_heading = curHeading`. -4. Else: `previous_heading = curHeading`, and if neither interpolating nor - motions pending: `fail_progress_count++`. - -### `MoveToManager::CheckProgressMade(float dist)` @ 0x005290f0 (lines 306385-306431) - -Implements the "moved ≥ 0.25 m/s averaged over the last 1 s AND the last sample-interval" test. - -```c -double elapsed_since_last_sample = cur_time - previous_distance_time; -if (elapsed_since_last_sample > 1.0) { - float instantaneous_speed = moving_away ? (dist - previous_distance) : (previous_distance - dist); - instantaneous_speed /= elapsed_since_last_sample; - if (instantaneous_speed > 0.25f) { - previous_distance = dist; - previous_distance_time = cur_time; - // ALSO check long-window speed - float total = moving_away ? (dist - original_distance) : (original_distance - dist); - total /= (cur_time - original_distance_time); - if (total > 0.25f) return 1; - } - return 0; -} -return 1; // not enough time elapsed yet to judge -``` - -Returns 1 = "making progress, don't increment fail counter"; 0 = "stuck". - -### `MoveToManager::CancelMoveTo(WeenieError)` @ 0x00529930 (lines 306886-306940) - -Drains `pending_actions` (free each node), `CleanUp(this)`, -`StopCompletely(physics_obj)`. Then the WeenieError is sent up to the weenie -object via the parent's CleanUpAndCallWeenie path. - -### `MoveToManager::CleanUp` @ 0x005295c0 (lines 306710-306736) - -```c -if (current_command != 0) _StopMotion(current_command, &local_params); -if (aux_command != 0) _StopMotion(aux_command, &local_params); -if (top_level_object_id != 0 && movement_type != Invalid) - CPhysicsObj::clear_target(physics_obj); -InitializeLocalVariables(this); -``` - -### `MoveToManager::HandleUpdateTarget(TargetInfo)` @ 0x0052a7d0 (lines 307802-307867) - -Server-driven callback. When a tracked target's position updates: - -```c -if (top_level_object_id != arg2->object_id) return; // not our target -if (initialized == 0) { // first snapshot - if (top_level_object_id == physics_obj->id) { // tracked self → done - sought_position = physics_obj->m_position; - CleanUpAndCallWeenie(this, current_target_position = physics_obj->m_position); - return; - } - if (arg2->status != Ok) { // bad snapshot - CancelMoveTo(this, 0x38); - return; - } - if (movement_type == MoveToObject) // first valid snapshot → build queue - MoveToObject_Internal(this, &arg2->target_position, &arg2->interpolated_position); - else if (movement_type == TurnToObject) - TurnToObject_Internal(this, &arg2->target_position); -} else { // ongoing - if (arg2->status != Ok) { CancelMoveTo(0x37); return; } - if (movement_type == MoveToObject) { - sought_position = arg2->interpolated_position; - current_target_position = arg2->target_position; - // RESET progress windows — target moved, so the old samples don't count - previous_distance = +inf; - previous_distance_time = cur_time; - original_distance = +inf; - original_distance_time = cur_time; - } -} -``` - -This is **the critical hookup for chase-AI**: every server `UpdateTarget` -shoves the pursuer's current_target forward and resets progress sampling. - -### `MoveToManager::HitGround` @ 0x00529d70 (lines 307175-307183) - -```c -if (movement_type != Invalid) - BeginNextNode(this); -``` - -Trigger from the body's contact-restored event. Used by AI move sequences -that start mid-air (knockback recovery, falling onto a target). - -### `MoveToManager::UseTime` @ 0x0052a780 (lines 307776-307798) — **the tick entry point** - -```c -if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) { // Contact bit - head_ = pending_actions.head_; - if (head_ != nullptr && - (top_level_object_id == 0 || movement_type == Invalid || initialized != 0)) { - if (head_->opcode == 7) tailcall HandleMoveToPosition(this); - if (head_->opcode == 9) tailcall HandleTurnToHeading(this); - } -} -``` - -**Branch summary:** -- Must have Contact (touching ground). -- Must have a pending action. -- Either there's no tracked target, OR movement is Invalid, OR we've been - initialized (got the first target snapshot). I.e., for `MoveToObject`, - `UseTime` is a no-op until `HandleUpdateTarget` flips `initialized=1`. - ---- - -## 4. MovementParameters - -### Layout - -`__inner0` is a packed bitfield (uint32, but the meaningful flags live in the -low 16 bits — we see `(int16_t)__inner0`). Confirmed bits: - -| Bit | Mask | ACE name | Meaning | -|---|---|---|---| -| 0 | 0x0001 | CanWalk | Allow WalkForward speed. | -| 1 | 0x0002 | CanRun | Allow RunForward speed. | -| 2 | 0x0004 | CanSidestep | | -| 3 | 0x0008 | CanWalkBackwards | | -| 4 | 0x0010 | CanCharge | (Holds key to apply) | -| 5 | 0x0020 | FailWalk | | -| 6 | 0x0040 | UseFinalHeading | Append `TurnToHeading(desired_heading)` after MoveToPosition. (Line 307571.) | -| 7 | 0x0080 | Sticky | After arrival, `StickTo(top_level_object_id, ...)` instead of stopping. (Line 307145 reads this as the high bit of `__inner0` low-byte and treats `< 0` as "set".) | -| 8 | 0x0100 | MoveAway | Used by `towards_and_away` to flip arrival/turn-direction conventions. | -| 9 | 0x0200 | MoveTowards | | -| 10 | 0x0400 | UseSpheres | | -| 11 | 0x0800 | SetHoldKey | | -| 12 | 0x1000 | Autonomous | | -| 13 | 0x2000 | ModifyRawState | | -| 14 | 0x4000 | ModifyInterpretedState | | -| 15 | 0x8000 | CancelMoveTo | line 307208 `& 0xffff7fff` — mask out before sending to inner motion. | -| 16 | 0x10000 | StopCompletely | | -| 17 | 0x20000 | DisableJumpDuringLink | | - -(Bit 7 ≥ "stick after arrive" is the load-bearing one for sticky-from-MoveTo; bit 6 -is load-bearing for "turn to face X after arriving".) - -### `MovementParameters::UnPackNet(MovementType type, void** stream, uint32_t bytes)` @ 0x0052ac50 (lines 308118-308190) - -The wire format the **client receives** (vs `Pack`/`UnPack` which is the local-save format). - -```c -size_required = (type == MoveToObject || type == MoveToPosition) ? 0x1c : 0x0c; -if (bytes < size_required || (type - 6) > 3) return 0; - -switch (type) { - case MoveToObject: - case MoveToPosition: - // 0x1c bytes: __inner0, distance_to_object, min_distance, fail_distance, speed, walk_run_threshold, desired_heading - this->__inner0 = read_uint32(); - this->distance_to_object = read_float(); - this->min_distance = read_float(); - this->fail_distance = read_float(); - this->speed = read_float(); - this->walk_run_threshhold= read_float(); - // desired_heading written below (shared) - break; - case TurnToObject: - case TurnToHeading: - // 0x0c bytes: __inner0, speed, desired_heading - this->__inner0 = read_uint32(); - this->speed = read_float(); - // desired_heading written below - break; -} -this->desired_heading = read_float(); -return 1; -``` - -Note: **UnPackNet is shorter than UnPack** (0x1c/0x0c vs 0x28). The wire format -omits `context_id`, `hold_key_to_apply`, and `action_stamp` — those are -local-only (server tracks them, doesn't ship them). Compare to the full -`UnPack` @ 0x0052abc0 which reads all 9 fields × 4 bytes = 0x28 bytes. - -### `MovementParameters::get_command(dist, heading_delta, &cmd, &holdKey, &movingAway)` @ 0x0052aa00 (lines 307946-308012) - -Decides which motion-command to issue based on flags + distance. - -Pseudocode (cleaner than the FP-mangled extract, validated against ACE port): - -```c -inner0 = this->__inner0; -if (inner0 & 0x0200) { // MoveTowards - if (inner0 & 0x0100) // MoveTowards AND MoveAway → use towards_and_away - towards_and_away(this, dist, heading, &cmd, &movingAway); - else if (dist > distance_to_object) { // Towards-only, still far → walk forward - cmd = 0x45000005; // WalkForward - movingAway = 0; - } else cmd = 0; -} else if (inner0 & 0x0100) { // MoveAway only - if (dist - min_distance < EPSILON) { // too close → walk back - cmd = 0x45000005; - movingAway = 1; - } else cmd = 0; -} else { // neither - if (dist > distance_to_object) { - cmd = 0x45000005; movingAway = 0; - } else cmd = 0; -} - -// Hold key: pick Run vs None based on CanRun, CanWalk, walk_run_threshhold -if (inner0 & 0x10) { // CanCharge / SetHoldKey route - *holdKey = HoldKey_Run; - return; -} -if ((inner0 & 0x02) == 0) { // !CanRun - *holdKey = HoldKey_None; - return; -} -if (inner0 & 0x01) { // CanWalk - if (dist - distance_to_object <= walk_run_threshhold) { - *holdKey = HoldKey_None; - return; - } -} -*holdKey = HoldKey_Run; -``` - -Magic numbers: -- `0x45000005` = WalkForward command -- `0x45000006` = WalkBackwards (used by `towards_and_away` when fleeing too close) -- `0x44000007` = RunForward (matched in `get_desired_heading`) - -### `MovementParameters::get_desired_heading(motion, movingAway)` @ 0x0052aad0 (lines 308016-308033) - -Returns 0 (chase forward) / 180 (chase backward / flee forward) / arg3 (other). -Used by `HandleMoveToPosition` to compute the desired heading offset from the -world-heading-to-target. - -```c -if (motion == 0x44000007 || motion == 0x45000005) return arg3; // forward → arg3=0 → face target -if (motion == 0x45000006) return arg3; // backward → arg3=180 → face target -return motion - 0x45000006; // (sentinel, only hit for non-forward/back) -``` - -(ACE port lines 186-198 hardcode this as `movingAway ? 180 : 0` for the -forward case, which is the same algebra after substituting in the actual -arg3 values the callers pass.) - ---- - -## 5. Interaction with PositionManager / InterpolationManager - -### `PositionManager::adjust_offset(Frame*, double quantum)` @ 0x00555190 (lines 352090-352118) - -Calls all three managers in sequence: - -```c -1. interpolation_manager->adjust_offset(arg2, quantum); // smooths server → local position over a window -2. sticky_manager->adjust_offset(arg2, quantum); // stick to a target -3. constraint_manager->adjust_offset(arg2, quantum); // clamp to a leash -``` - -**Order matters**: -- InterpolationManager runs first, baking server-driven catch-up into the offset. -- StickyManager then **reads from physics_obj's m_position** (which is already - interpolated by the previous step's commit — the offset hasn't been applied - yet, but m_position reflects last frame's solved state). Sticky overwrites - the offset if it has a target. -- ConstraintManager comes last, scaling-down or zeroing whatever the others - produced if it would push us past a leash radius. - -Each manager **reads** from `physics_obj->m_position` and **writes** the -per-tick offset (translation + rotation delta) into `arg2`. The caller then -composes that offset onto `physics_obj->m_position` for the actual move. - -### MoveToManager interaction - -MoveToManager **does not** participate in `PositionManager::adjust_offset`. It -runs once per tick from a different entry point (`UseTime`, called from the -physics scheduler) and **issues motion commands** to `CMotionInterp` via -`_DoMotion` / `_StopMotion`. The body's velocity comes from -`CMotionInterp::apply_current_movement`, not from MoveToManager directly. - -So the layering is: -1. Pending nodes → `_DoMotion(MoveTo*)` → `CMotionInterp::DoInterpretedMotion(RunForward + HoldKey.Run)` -2. `CMotionInterp` writes `InterpretedState.ForwardCommand=RunForward, HoldKey=Run` -3. Each tick, `apply_current_movement` reads InterpretedState and emits a body velocity -4. PositionManager (Interp+Sticky+Constraint) post-modifies the per-tick offset - -When sticky activates from MoveToManager (after arrival), MoveToManager calls -`PositionManager::StickTo`, which creates the sticky and from then on the -sticky's `adjust_offset` overrides the body's natural velocity each tick. - ---- - -## 6. Differences vs acdream `RemoteMoveToDriver.cs` - -acdream's current port is intentionally minimal (header comment lines 44-57). -What it **DOES** correctly: -- Heading delta with 20° snap tolerance — line 307255-307287 of retail. -- Arrival predicate via `min_distance` for chase / `distance_to_object` for flee - — matches retail (lines 307309/307323), explicitly diverges from ACE. -- Stale-destination giveup at 1.5 s (acdream-specific safety net for our - streaming model). - -What it **OMITS** vs retail (acceptable for a remote-observer of a server- -authored creature): -- Pending-action queue (TurnToHeading → MoveToPosition → final TurnToHeading). - Server re-emits the move; we don't need to schedule sub-nodes. -- Sticky-after-arrive (`__inner0 & 0x0080`). Server signals stick separately - via `PositionManager::StickTo` calls or via re-emitted MoveTos. -- `CheckProgressMade` / `fail_progress_count`. Server-side concern; if the - remote AI gives up, the server just stops sending MoveTo updates. -- `set_target_quantum` adaptive tick rate. We run at fixed 60 Hz. -- `HandleUpdateTarget` re-tracking. Server re-emits the full MoveTo when its - target moves; we re-parse and re-init. -- ConstraintManager and `transient_state & Contact` gating. We don't have a - real contact-plane test on remotes (we only do collision for the local - player). For remotes, we always assume "on ground". -- StickyManager altogether — there's no scenario where a remote needs to - follow a target the server hasn't already told us to face via UpdateMotion. - -What's a **real port gap** worth filing for L.3 follow-up: -- `MovementParameters::__inner0` flag bit 0x40 (`UseFinalHeading`) — when the - packet's final-heading bit is set, the body should rotate to face - `desired_heading` after arrival. We currently ignore this and the remote - ends in whatever heading the last steering tick produced. -- `MovementParameters::__inner0` flag bit 0x80 (Sticky-after-arrive) — when - set, we should latch onto `top_level_object_id` instead of going idle. For - an adventuring monster following the player, this matters: today we'd let - the remote stand still for ~1 s (server's MoveTo re-emit cadence) then - steer again, instead of locking on smoothly. Worth filing as `#L.X`. -- `transient_state & 1` (Contact) gating. If a remote is mid-air (knocked - back, jumping), `RemoteMoveToDriver` shouldn't drive horizontal motion - toward the destination. We currently steer regardless of grounded state. - ---- - -## Appendix: full retail line-citation index (this doc only) - -| Function | Address | Pseudo-C lines | -|---|---|---| -| StickyManager::Create | 0x00555800 | 352620-352633 | -| StickyManager::Destroy | 0x00555650 | 352521-352540 | -| StickyManager::SetPhysicsObject | 0x005556e0 | 352544-352555 | -| StickyManager::StickTo | 0x00555710 | 352559-352578 | -| StickyManager::HandleUpdateTarget | 0x00555780 | 352582-352607 | -| StickyManager::UnStick | 0x00555400 | 352335-352346 | -| StickyManager::adjust_offset | 0x00555430 | 352351-352494 | -| StickyManager::UseTime | 0x00555610 | 352498-352517 | -| StickyManager::~StickyManager | 0x005557e0 | 352611-352616 | -| ConstraintManager::Create | 0x00556110 | 353442-353474 | -| ConstraintManager::SetPhysicsObject | 0x00556090 | 353388-353401 | -| ConstraintManager::ConstrainTo | 0x00556240 | 353528-353537 | -| ConstraintManager::UnConstrain | 0x005560c0 | 353405-353409 | -| ConstraintManager::IsFullyConstrained | 0x005560d0 | 353413-353427 | -| ConstraintManager::adjust_offset | 0x00556180 | 353479-353524 | -| ConstraintManager::~ConstraintManager | 0x005560f0 | 353431-353438 | -| MoveToManager::MoveToManager | 0x005293b0 | 306554-306593 | -| MoveToManager::Create | 0x00529470 | 306597-306614 | -| MoveToManager::Destroy | 0x005294b0 | 306618-306663 | -| MoveToManager::InitializeLocalVariables | 0x00529250 | 306490-306534 | -| MoveToManager::PerformMovement | 0x0052a900 | 307871-307904 | -| MoveToManager::MoveToObject | 0x00529680 | 306756-306817 | -| MoveToManager::TurnToObject | 0x005297d0 | 306820-306882 | -| MoveToManager::MoveToPosition | 0x0052a240 | 307521-307593 | -| MoveToManager::TurnToHeading | 0x0052a630 | 307706-307772 | -| MoveToManager::MoveToObject_Internal | 0x0052a400 | 307597-307663 | -| MoveToManager::TurnToObject_Internal | 0x0052a550 | 307667-307702 | -| MoveToManager::AddTurnToHeadingNode | 0x00529530 | 306667-306685 | -| MoveToManager::AddMoveToPositionNode | 0x00529580 | 306689-306706 | -| MoveToManager::RemovePendingActionsHead | 0x00529380 | 306538-306550 | -| MoveToManager::CleanUp | 0x005295c0 | 306710-306736 | -| MoveToManager::CleanUpAndCallWeenie | 0x00529650 | 306740-306752 | -| MoveToManager::CancelMoveTo | 0x00529930 | 306886-306940 | -| MoveToManager::~MoveToManager | 0x005299d0 | 306945-306953 | -| MoveToManager::BeginMoveForward | 0x00529a00 | 306957-307042 | -| MoveToManager::BeginTurnToHeading | 0x00529b90 | 307046-307120 | -| MoveToManager::BeginNextNode | 0x00529cb0 | 307123-307171 | -| MoveToManager::HitGround | 0x00529d70 | 307175-307183 | -| MoveToManager::HandleMoveToPosition | 0x00529d80 | 307187-307438 | -| MoveToManager::HandleTurnToHeading | 0x0052a0c0 | 307442-307517 | -| MoveToManager::UseTime | 0x0052a780 | 307776-307798 | -| MoveToManager::HandleUpdateTarget | 0x0052a7d0 | 307802-307867 | -| MoveToManager::CheckProgressMade | 0x005290f0 | 306385-306431 | -| MoveToManager::GetCurrentDistance | 0x005291b0 | 306435-306460 | -| MoveToManager::is_moving_to | 0x00529220 | 306464-306470 | -| MoveToManager::_DoMotion | 0x00529010 | 306351-306364 | -| MoveToManager::_StopMotion | 0x00529080 | 306368-306381 | -| MovementParameters::towards_and_away | 0x0052a9a0 | 307917-307942 | -| MovementParameters::get_command | 0x0052aa00 | 307946-308012 | -| MovementParameters::get_desired_heading | 0x0052aad0 | 308016-308033 | -| MovementParameters::Pack | 0x0052ab20 | 308037-308074 | -| MovementParameters::UnPack | 0x0052abc0 | 308078-308114 | -| MovementParameters::UnPackNet | 0x0052ac50 | 308118-308190 | diff --git a/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md b/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md deleted file mode 100644 index 76a68d9b..00000000 --- a/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md +++ /dev/null @@ -1,824 +0,0 @@ -# L.3 port — `update_object` substepping + `Frame` operations - -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decompilation, BinaryNinja pseudo-C). Cross-checked against ACE's port (`references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs`, `PhysicsGlobals.cs`). - -This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers: - -- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder) -- What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame) -- How is `LastUpdateTime` advanced? (always to `PhysicsTimer::curr_time` after the loop) -- What does `process_hooks` do? (iterates linked-list of `PhysicsObjHook`s + `anim_hooks` per frame, executing & removing finished ones) -- `Frame::combine` semantics: `out = a · b` — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product `a.q * b.q` for orientation). - -The constants `MinQuantum`/`MaxQuantum`/`HugeQuantum` are **not directly visible** in the BinaryNinja decompiled `update_object` because the BN decompiler corrupted some immediate floats to `0.0`. The constants are recovered cleanly from ACE's `PhysicsGlobals.cs`, which itself is a faithful port of the same retail binary. The `0.000199999995f` (= `EPSILON = 0.0002`) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from `MinQuantum`. - ---- - -## 1 — `CPhysicsObj::update_object` (FUN_00515D10) — main per-frame entry - -**Signature:** `void __fastcall CPhysicsObj::update_object(CPhysicsObj* this)` -**Source:** `acclient_2013_pseudo_c.txt:283950-284055` - -### Verbatim relevant pseudo-C (lines 283950-284055) - -``` -00515d10 void __fastcall CPhysicsObj::update_object(class CPhysicsObj* this) { - - // Bail-out 1: parented (held by another obj), no cell, or hidden (state & 0x1000000) - if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) { - this->transient_state &= 0xffffff7f; // clear "active" flag - return; - } - - // Player-distance update: if a player object exists, compute offset and - // toggle "active" transient flag based on 96.0f distance gate. - CPhysicsObj* player = CPhysicsObj::player_object; - if (player != 0) { - Vector3 offset; - Position::get_offset(&player->m_position, &offset, &this->m_position); - this->player_vector = offset; - this->player_distance = sqrtf(offset.x*offset.x + offset.y*offset.y + offset.z*offset.z); - // [actually plain |offset.x| in BN noise; ACE uses .Length()] - if (this->player_distance >= 96.0f) { - // beyond the active radius; deactivate - this = CPhysicsObj::obj_maint; // this overwritten — BN noise - } - if (this->player_distance >= 96.0f || this->part_array == 0) { - this = this_3; - CPhysicsObj::set_active(this, 1); - } else { - this_3->transient_state &= 0xffffff7f; // clear active - } - } - - // ── dt computation ──────────────────────────────────────────────────────── - double dt = Timer::cur_time - this_3->update_time; - PhysicsTimer::curr_time = this_3->update_time; // seed phys clock with this obj's last-update - - // ── Guard 1: dt < EPSILON (0.000199999995f ≈ 0.0002 s) ──────────────────── - // Retail tolerance for "essentially zero" — NOT MinQuantum. - // If dt < EPSILON, bump update_time and return without any simulation. - if (dt < 0.000199999995f) { // line 283996 - this_3->update_time = Timer::cur_time; - return; - } - - // ── Guard 2: dt > HugeQuantum (2.0 s) — discard stale dt ───────────────── - // (Constant 2.0 visible at line 284009 — "long double temp1 = 2.0;") - if (dt > 2.0) { - this_3->update_time = Timer::cur_time; - return; - } - - // ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ───── - // BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure - // is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1. - if (dt > 0.0f /* MaxQuantum=0.1 */) { - do { - PhysicsTimer::curr_time += /* MaxQuantum */ 0.1; - CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f); - dt -= /* MaxQuantum */ 0.1; - } while (dt > /* MaxQuantum */ 0.1); - } - - // ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ──── - // BN: line 284046 "if (!(p_1) || ... > 0.0) { ... UpdateObjectInternal(remainder) - // }" — the comparison constant should be MinQuantum=1/30=0.0333f per ACE. - if (dt > /* MinQuantum */ 0.0333f) { - PhysicsTimer::curr_time += dt; - CPhysicsObj::UpdateObjectInternal(this_3, (float)dt); - } - - // Advance update_time to the consumed phys clock time. - this_3->update_time = PhysicsTimer::curr_time; -} -``` - -### Constants — recovered values - -From `references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43`: - -| Symbol | Hex (float32) | Value | Meaning | -|---|---|---|---| -| `EPSILON` | `0x3949A18A` | `0.000199999995f` ≈ 0.0002 s | "essentially zero" tolerance (visible in retail decomp line 283996) | -| `MinQuantum` | `0x3D088889` | `1.0f / 30.0f ≈ 0.03333` s (30 fps) | minimum simulation step | -| `MaxQuantum` | `0x3DCCCCCD` | `0.1f` (10 fps) | substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE | -| `HugeQuantum` | `0x40000000` | `2.0f` (0.5 fps) | upper bound — beyond this, dt is discarded as stale (visible line 284009) | - -**Note on the BN-decomp corruption:** lines 284034, 284036-284037, 284049 in the pseudo-C show `((long double)0.0)` where retail clearly reads non-zero immediates from `.rdata`. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard. - -### `update_object_server` — does it exist? - -**No.** Search of `acclient_2013_pseudo_c.txt` for `update_object_server` returns zero hits. There is only `CPhysicsObj::update_object` (the per-frame driver) and `CPhysicsObj::UpdateObjectInternal` (the per-substep worker). ACE introduced server-side variants that don't exist in retail. - ---- - -## 2 — `CPhysicsObj::UpdateObjectInternal` (FUN_005156B0) — per-substep worker - -**Signature:** `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float arg2)` -**Source:** `acclient_2013_pseudo_c.txt:283611-283757` - -This is the function called once per substep with `arg2 = dt` (≤ MaxQuantum). Two main branches based on `transient_state` sign bit (which is `Active`, 0x80): - -``` -005156b0 void UpdateObjectInternal(CPhysicsObj* this, float arg2) { - - // Branch A: obj is INACTIVE (transient_state >= 0, i.e. high bit clear). - // Just tick particles + scripts; no movement. - if ((int16_t)this->transient_state >= 0) goto label_5159b8; - - // Branch B: obj is ACTIVE. - if (this->cell == 0) return; - - // ── Active-mover path ─────────────────────────────────────────────────── - if ((this->transient_state & 0x100) != 0) // line 283631 — clears Sticky - CPhysicsObj::set_ethereal(this, 0, 0); - this->jumped_this_frame = 0; - - // Build a local Frame (stack-allocated, identity quaternion). - Position offsetPos = { objcell_id=0x796910, qw=1, qx=0,qy=0,qz=0, - origin={0,0,0} }; - Frame offsetFrame; // stack - Frame::cache(&offsetFrame); // line 283644 - - uint32_t cellId = this->m_position.objcell_id; - - // ── 1) UpdatePositionInternal: integrates velocity/accel into offsetFrame - st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &offsetFrame); - // line 283646 - - CPartArray* parts = this->part_array; - uint32_t numSpheres = parts ? CPartArray::GetNumSphere(parts) : 0; - - if (parts != 0 && numSpheres != 0) { - if (Vector3::operator==(&offsetFrame.origin, &this->m_position.frame.origin) == 0) { - // origin moved — need a transition (collision sweep) - uint32_t state = this->state; - if ((state & 0x100) != 0) { // line 283661 - // facing-velocity heading mode - Vector3 dir; - AC1Legacy::Vector3::operator-(&offsetFrame.origin, &dir, - &this->m_position.frame.origin); - Vector3::Normalize(&dir); - Frame::set_vector_heading(&offsetFrame, &dir); - } - else if ((state & "activation type (%s) with '%s' b…" /* a high state-bit */) != 0 - && AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0) { - float heading = AC1Legacy::Vector3::get_heading(&this->m_velocityVector); - Frame::set_heading(&offsetFrame, heading); - } - - // ── COLLISION SWEEP — port of FUN_005148A0 / Transition::FindTransitional… ── - CTransition* tx = CPhysicsObj::transition(this, &this->m_position, - &offsetPos /* desired */, - /*flags*/ 0); - - if (tx == 0) { - // sweep failed — keep current position, snap to offsetFrame, zero velocity - CPhysicsObj::set_frame(this, &offsetFrame); - this->cached_velocity = {0,0,0}; - } else { - // sweep succeeded — measured velocity = (curr_pos - new_pos)/dt - Vector3 deltaPos; - Position::get_offset(&this->m_position, &deltaPos, &tx->sphere_path.curr_pos); - Vector3 measuredVel; - Vector3::operator/(&deltaPos, &measuredVel, arg2); - this->cached_velocity = measuredVel; - CPhysicsObj::SetPositionInternal(this, tx); // commits new pos+cell - } - } else { - // origin didn't move — just set frame and clear velocity - CPhysicsObj::set_frame(this, &offsetFrame); - this->cached_velocity = {0,0,0}; - } - } else { - // No part_array or no spheres — clear "stationary fall" flag if free, set frame, clear velocity - if (this->movement_manager == 0) { - uint32_t ts = this->transient_state; - if ((ts & 2) != 0) this->transient_state = ts & 0xffffff7f; - } - CPhysicsObj::set_frame(this, &offsetFrame); - this->cached_velocity = {0,0,0}; - } - - // ── 2) Per-frame ticks (managers + parts + position interp) ───────────── - if (this->detection_manager != 0) - DetectionManager::CheckDetection(this->detection_manager); - if (this->target_manager != 0) - TargetManager::HandleTargetting(this->target_manager); - if (this->movement_manager != 0) - MovementManager::UseTime(this->movement_manager); // animation tick - if (this->part_array != 0) - CPartArray::HandleMovement(this->part_array); - if (this->position_manager != 0) - PositionManager::UseTime(this->position_manager); - -label_5159b8: - // ── 3) Particles + scripts (always, both Active and Inactive branches) ── - if (this->particle_manager != 0) - ParticleManager::UpdateParticles(this->particle_manager); - if (this->script_manager != 0) - ScriptManager::UpdateScripts(this->script_manager); -} -``` - -**Key sequencing per substep:** - -1. Build identity local `Frame` (`offsetFrame`). -2. `UpdatePositionInternal(this, dt, &offsetFrame)` — integrate motion into the frame. -3. If origin changed and we have collidable spheres → run `transition()` (collision sweep). -4. If sweep succeeds → commit via `SetPositionInternal`; cached_velocity = (deltaPos / dt). -5. If sweep fails → snap to `offsetFrame` directly; zero velocity. -6. Tick managers (Detection, Target, Movement, PositionManager). -7. Tick CPartArray::HandleMovement (per-part frame propagation). -8. Tick particle_manager + script_manager. - -`process_hooks` is NOT called here — it lives inside `UpdatePositionInternal` (see §3). - ---- - -## 3 — `CPhysicsObj::UpdatePositionInternal` (FUN_00512C30) - -**Signature:** `void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)` -**Source:** `acclient_2013_pseudo_c.txt:280817-280866` - -``` -00512c30 void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame) { - - // ── Step A: tabula-rasa local frame, zero translation ─────────────────── - Frame localFrame; - localFrame.qw=1, localFrame.qx=0,qy=0,qz=0; - localFrame.origin = {0,0,0}; - Frame::cache(&localFrame); // build l2gv basis from quat - - // ── Step B: animation drives a delta-frame into localFrame ────────────── - // (Skipped if state & 0x4000 / "static decoration" bit.) - if ((this->state & 0x4000) == 0) { - if (this->part_array != 0) { - // CPartArray::Update walks the AnimSequencer, applies animFrame deltas, - // adds them onto var_c/var_8/var_4 (the local origin). It also pulls - // OmegaVector and applies it into the quaternion. After this returns, - // localFrame.origin holds the local-frame velocity*dt + omega-rotation. - CPartArray::Update(this->part_array, dt, &localFrame); - } - - // Scale by m_scale if Sticky flag is set (riding a moving platform). - // Otherwise zero the local-origin (just keep rotation). - if ((this->transient_state & 2) /* HasContact */ == 0) { - localFrame.origin *= 0.0f; // zero translation - } else { - localFrame.origin *= this->m_scale; - } - } - - // ── Step C: apply position_manager interpolation offset (smooth catch-up) - if (this->position_manager != 0) - PositionManager::adjust_offset(this->position_manager, &localFrame, dt); - - // ── Step D: COMBINE — outFrame = m_position.frame * localFrame ────────── - // This rotates localFrame.origin by m_position.frame's basis, adds m_position.frame's - // origin, multiplies the quaternions: outFrame.q = m_position.q * localFrame.q. - Frame::combine(outFrame, &this->m_position.frame, &localFrame); // line 280860 - - // ── Step E: if not "static decoration", run physics (gravity, friction…) - if ((this->state & 0x4000) == 0) - CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame); - - // ── Step F: dispatch hooks (per-frame scripted callbacks + anim hooks) ── - CPhysicsObj::process_hooks(this); // line 280865 -} -``` - -This is the function that produces the **desired post-tick world frame** in `outFrame`. The caller (`UpdateObjectInternal`) then routes that through the collision sweep. - ---- - -## 4 — `CPhysicsObj::process_hooks` (FUN_00511550) - -**Signature:** `void __fastcall CPhysicsObj::process_hooks(CPhysicsObj* this)` -**Source:** `acclient_2013_pseudo_c.txt:279431-279486` - -``` -00511550 void process_hooks(CPhysicsObj* this) { - - // ── Linked-list hooks (vtable->Execute) ───────────────────────────────── - // PhysicsObjHook is a polymorphic interface (translucency-fade, scale-fade, - // visibility-fade, FPHook, etc). When Execute returns nonzero, the hook is - // "done" — unlink and delete it. - PhysicsObjHook* h = this->hooks; - while (h != 0) { - PhysicsObjHook* next = h->next; - if (h->vtable->Execute(this) != 0) { - // unlink h from doubly-linked list - if (h->next != 0) h->next->prev = h->prev; - if (h->prev == 0) this->hooks = h->next; - else h->prev->next = h->next; - h->prev = h->next = 0; - h->vtable = (vtable_t*)0x7c6b20; // PhysicsObjHook base vtable - operator delete(h); - } - h = next; - } - - // ── Anim hooks (one-shot bag, executed and cleared every frame) ───────── - uint32_t n = this->anim_hooks.m_num; - if (n > 0) { - for (uint32_t i = 0; i < this->anim_hooks.m_num; i++) - this->anim_hooks.m_data[i]->vtable->Execute(this); - AC1Legacy::SmartArray::shrink(&this->anim_hooks); - this->anim_hooks.m_num = 0; - } -} -``` - -**What it does:** - -- `hooks` (linked list): persistent-until-done callbacks (translucency lerp, scale lerp, FPHook for fade events, etc). Each `Execute` returns done=1 → delete. -- `anim_hooks` (`SmartArray`): one-shot per-frame anim events (sound triggers, particle spawns, attack-frame markers fired by AnimationSequencer). Always cleared every frame. - -This is invoked once per substep at the END of `UpdatePositionInternal`. acdream's port has separate routers (`AnimationHookRouter`, `AnimationCommandRouter`) but no equivalent of the persistent `PhysicsObjHook` linked list yet. - ---- - -## 5 — `CPhysicsObj::calc_acceleration` (FUN_00510950) - -**Signature:** `void __fastcall CPhysicsObj::calc_acceleration(CPhysicsObj* this)` -**Source:** `acclient_2013_pseudo_c.txt:278533-278560` - -``` -00510950 void calc_acceleration(CPhysicsObj* this) { - uint8_t ts = (int8_t)this->transient_state; - - // Special case: Active + HasContact + state-bit-?? → freeze (zero accel + omega). - // Used for "standing still on a surface" steady-state. - if ((ts & 1) != 0 && (ts & 2) != 0 && (this->state & 0x100 /* state's high mask */) == 0) { - this->m_accelerationVector = {0, 0, 0}; - this->m_omegaVector = {0, 0, 0}; - return; - } - - // Gravity gate: state bit 0x4 (= GravityFlag). - if ((this->state & 0x400 /* gravity bit, 0x4 << 8 in BN ushort masking */) == 0) { - // Gravity OFF — zero acceleration (note: omega NOT zeroed) - this->m_accelerationVector = {0, 0, 0}; - return; - } - - // Default: gravity ON → vertical acceleration = PhysicsGlobals::gravity - this->m_accelerationVector = {0, 0, PhysicsGlobals::gravity}; // gravity ≈ -9.8 -} -``` - -**Per-frame called by `UpdatePhysicsInternal` (which is called from `UpdatePositionInternal` step E above)**. acdream's `PhysicsBody.calc_acceleration` matches this contract. - ---- - -## 6 — `CPhysicsObj::transition` (FUN_00512DC0) - -**Signature:** `CTransition const* transition(CPhysicsObj* this, Position const* fromPos, Position const* toPos, int32_t flags)` -**Source:** `acclient_2013_pseudo_c.txt:280904-280957` - -``` -00512dc0 CTransition* transition(CPhysicsObj* this, Position* from, Position* to, int32_t flags) { - CTransition* tx = CTransition::makeTransition(); - if (tx == 0) return 0; - - // Init the object info struct (collidesWith, isMissile, etc) using flags arg - CTransition::init_object(tx, this, CPhysicsObj::get_object_info(this, tx, flags)); - - // Init sphere(s) to sweep — typically 1 humanoid sphere or N for parts - CPartArray* parts = this->part_array; - uint32_t n = parts ? CPartArray::GetNumSphere(parts) : 0; - if (parts == 0 || n == 0) { - CTransition::init_sphere(tx, 1, &dummy_sphere, 1.0f); - } else { - float scale = this->m_scale; - CSphere* spheres = CPartArray::GetSphere(parts); - uint32_t nSph = CPartArray::GetNumSphere(parts); - CTransition::init_sphere(tx, nSph, spheres, scale); - } - - // Path: from → to in cell `this->cell` - CTransition::init_path(tx, this->cell, from, to); - - // Stationary-fall mask: tighter checks based on transient_state ContactPlane bits - uint8_t ts = (int8_t)this->transient_state; - if ((ts & 0x40) != 0) tx->collision_info.frames_stationary_fall = 3; - else if ((ts & 0x20) != 0) tx->collision_info.frames_stationary_fall = 2; - else if ((ts & 0x10) != 0) tx->collision_info.frames_stationary_fall = 1; - - // Run the actual sweep — returns nonzero on success - int32_t ok = CTransition::find_valid_position(tx); - - // NOTE: BN shows cleanupTransition(tx) BEFORE the success check — this - // looks wrong but BN's stack-frame analysis is unreliable here. ACE's - // port (PhysicsObj.transition) calls cleanup AFTER, conditionally. - CTransition::cleanupTransition(tx); - return (ok != 0) ? tx : 0; -} -``` - -`find_valid_position` is just an alias that calls `find_transitional_position` (line 273898). The actual sweep loop is `CTransition::find_transitional_position` (FUN_0050BDF0) at line 273613, which: - -1. Computes step count (`calc_num_steps`) — `dt-derived` based on offset length and sphere radius. -2. Loops, advancing the sphere along the offset, calling `transitional_insert` each step. -3. Each step: cell list → BSP collision → step-up / edge-slide / contact-plane logic. - -This is the heart of the "collision sweep" the env-var path currently bypasses. - ---- - -## 7 — `CPhysicsObj::SetPositionInternal` overloads - -Two overloads exist. The "post-sweep commit" form is FUN_00515BD0, called with `(this, ebp /*tx*/)`: - -``` -00515bd0 SetPositionError SetPositionInternal(CPhysicsObj* this, Position* pos, - SetPositionStruct* sps, CTransition* tx) { - CSphere* localSph = tx->sphere_path.local_sphere; - if (this->cell == 0) CPhysicsObj::prepare_to_enter_world(this); - - int32_t ecx_2 = (sps->flags >> 5) & 1; // "AdjustPosition" flag - CTransition* outTx = nullptr; - CPhysicsObj::AdjustPosition(pos, localSph, &outTx, ecx_2, 1); - - if (outTx == 0) { - // Off the map — go to "lost cell" - CPhysicsObj::prepare_to_leave_visibility(this); - CPhysicsObj::store_position(this, pos); - CObjectMaint::GotoLostCell(CPhysicsObj::obj_maint, this, this->m_position.objcell_id); - this->transient_state &= 0xffffff7f; - } else { - // Hooks/Storage/Corpses go through ForceIntoCell - if (this->weenie_obj != 0) { - if (weenie_obj->IsHook()) return CPhysicsObj::ForceIntoCell(this, outTx, pos); - if (weenie_obj->IsStorage()) return CPhysicsObj::ForceIntoCell(this, outTx, pos); - if (weenie_obj->IsCorpse()) return CPhysicsObj::ForceIntoCell(this, outTx, pos); - } - // Honor "do_not_load_cells" sps flag - if ((sps->flags & 0x20) != 0) tx->cell_array.do_not_load_cells = 1; - - if (CPhysicsObj::CheckPositionInternal(this, outTx, pos, tx, sps) == 0) { - int32_t r = CPhysicsObj::handle_all_collisions(this, &tx->collision_info, 0, 0); - return (-r ^ -r ... & 2) + 2; // BN noise — actually returns 2 or 3 - } - if (tx->sphere_path.curr_cell == 0) return 3; // CELL_FAILED - CPhysicsObj::SetPositionInternal(this, tx); // 1-arg form: commit - } - return 0; // OK_SPE -} -``` - -The 1-arg form (`SetPositionInternal(this, tx)`) is the one that finally writes the new cell pointer + frame onto the object (it's not in this excerpt — it's the real "commit" routine). - ---- - -## 8 — `CPhysicsObj::SetPosition` (FUN_005160C0) - -External wrapper that builds a CTransition, runs SetPositionInternal, and returns. Used by NPC teleport / scatter, NOT by `update_object`. - -``` -005160c0 SetPositionError SetPosition(CPhysicsObj* this, SetPositionStruct* sps) { - CTransition* tx = CTransition::makeTransition(); - if (tx == 0) return 1; - CTransition::init_object(tx, this, 0); - // Init sphere(s) — same pattern as transition() - CTransition::init_sphere(tx, n, spheres, scale); - SetPositionError r = CPhysicsObj::SetPositionInternal(this, sps, tx); - CTransition::cleanupTransition(tx); - return r; -} -``` - -## 9 — `CPhysicsObj::SetPositionSimple` (FUN_005162B0) - -``` -005162b0 SetPositionError SetPositionSimple(CPhysicsObj* this, Position* pos, int32_t teleport) { - uint32_t flags = teleport ? 0x1012 : 0x1002; - SetPositionStruct sps; - SetPositionStruct::SetPositionStruct(&sps); - SetPositionStruct::SetPosition(&sps, pos); - SetPositionStruct::SetFlags(&sps, flags); - SetPositionError r = CPhysicsObj::SetPosition(this, &sps); - SetPositionStruct::~SetPositionStruct(&sps); - return r; -} -``` - -## 10 — `CPhysicsObj::set_frame` (FUN_00514090) - -The "no collision check" frame setter — used inside the substep when origin didn't move OR when the sweep failed and we just snap. - -``` -00514090 void set_frame(CPhysicsObj* this, Frame* arg2) { - Frame newFrame; - Frame::operator=(&newFrame, arg2); - if (Frame::IsValid(&newFrame) == 0 && Frame::IsValidExceptForHeading(&newFrame) != 0) { - // NaN-only-in-quaternion edge case → reset rotation (memset to 0) - newFrame.qw = 0; newFrame.qx = 0; newFrame.qy = 0; newFrame.qz = 0; - } - Frame::operator=(&this->m_position.frame, &newFrame); // store - if ((this->state & 0x1000 /* "no parts" */) == 0) { - if (this->part_array != 0) - CPartArray::SetFrame(this->part_array, &this->m_position.frame); - } - CPhysicsObj::UpdateChildrenInternal(this); // propagate to children -} -``` - ---- - -## 11 — `Frame` operations - -### Memory layout (verbatim from `acclient.h` / inferred from BN offsets) - -``` -class Frame { - Vector3 m_fOrigin; // +0x00 (12 bytes) - float qw, qx, qy, qz; // +0x0C (16 bytes) - float m_fl2gv[9]; // +0x1C (36 bytes) — 3x3 local-to-global rotation matrix cache -}; // total 0x40 = 64 bytes -``` - -The matrix cache `m_fl2gv` is the rotation matrix derived from the quaternion. It's recomputed by `Frame::cache` whenever the quaternion changes. - -### `Frame::operator=` (FUN_00425C30) — line 39761 - -Plain memberwise copy of all 16 floats (origin + quat + 9 matrix entries): - -``` -00425c30 Frame& operator=(Frame* this, Frame const& src) { - this->m_fOrigin = src.m_fOrigin; - this->qw = src.qw; this->qx = src.qx; this->qy = src.qy; this->qz = src.qz; - for (int i = 0; i < 9; i++) this->m_fl2gv[i] = src.m_fl2gv[i]; - return *this; -} -``` - -### `Frame::cache` (FUN_00534DF0) — line 319353 - -Rebuilds the `m_fl2gv[9]` rotation matrix from `(qw, qx, qy, qz)`. Standard quaternion-to-matrix: - -``` -00534df0 void Frame::cache(Frame* this) { - // Use temp doubles to preserve x87 precision - double tx = this->qx + this->qx; // 2qx - double ty = this->qy + this->qy; // 2qy - double tz = this->qz + this->qz; // 2qz - double wx = this->qw * tx; // 2qw·qx - double wy = this->qw * ty; // 2qw·qy - double wz = this->qw * tz; // 2qw·qz - double xx = this->qx * tx; // 2qx·qx - double xy = this->qx * ty; // 2qx·qy - double xz = this->qx * tz; // 2qx·qz - double yy = this->qy * ty; // 2qy·qy - double yz = this->qy * tz; // 2qy·qz - double zz = this->qz * tz; // 2qz·qz - - // Column-major 3x3 stored row-by-row: - this->m_fl2gv[0] = 1.0 - yy - zz; // R00 - this->m_fl2gv[1] = xy + wz; // R10 - this->m_fl2gv[2] = xz - wy; // R20 - this->m_fl2gv[3] = xy - wz; // R01 - this->m_fl2gv[4] = 1.0 - xx - zz; // R11 - this->m_fl2gv[5] = yz + wx; // R21 - this->m_fl2gv[6] = xz + wy; // R02 - this->m_fl2gv[7] = yz - wx; // R12 - this->m_fl2gv[8] = 1.0 - xx - yy; // R22 -} -``` - -This is a standard XYZW-quaternion-to-3x3 matrix, but the layout here is **transpose** of typical glm/Silk row-major. acdream needs to be careful when consuming. - -### `Frame::combine` (FUN_005122E0) — line 280355 - -**Most important — multiplication semantics: `out = a · b`** (compose b on top of a). - -``` -005122e0 void Frame::combine(Frame* out, Frame const* a, Frame const* b) { - // ── Origin: rotate b.origin by a's basis, then add a.origin ───────────── - out->m_fOrigin.x = a->m_fl2gv[0]*b->origin.x - + a->m_fl2gv[3]*b->origin.y - + a->m_fl2gv[6]*b->origin.z + a->m_fOrigin.x; - out->m_fOrigin.y = a->m_fl2gv[1]*b->origin.x - + a->m_fl2gv[4]*b->origin.y - + a->m_fl2gv[7]*b->origin.z + a->m_fOrigin.y; - out->m_fOrigin.z = a->m_fl2gv[2]*b->origin.x - + a->m_fl2gv[5]*b->origin.y - + a->m_fl2gv[8]*b->origin.z + a->m_fOrigin.z; - - // ── Quaternion: a.q * b.q (Hamilton product) ──────────────────────────── - // Note: BN swaps some operand orders, but this is the Hamilton product. - Frame::set_rotate(out, - a->qw*b->qw - b->qx*a->qx - b->qy*a->qy - b->qz*a->qz, // qw - a->qw*b->qx + b->qz*a->qy + b->qw*a->qx - b->qy*a->qz, // qx - b->qy*a->qw - b->qz*a->qx + a->qz*b->qx + a->qy*b->qw, // qy - b->qy*a->qx + b->qz*a->qw - a->qy*b->qx + a->qz*b->qw); // qz -} -``` - -`Frame::set_rotate` then normalizes the quaternion and re-runs `Frame::cache` to refresh `m_fl2gv`. - -**Order:** `combine(out, a, b)` means `out = a ∘ b` — first apply b in local-frame coords, then rotate-and-translate by a. In `UpdatePositionInternal` step D, this means: `outFrame = m_position.frame * localDelta` — i.e. take the local-frame motion and lift it into world. - -### `Frame::set_rotate` (FUN_00535080) — line 319453 - -``` -00535080 void Frame::set_rotate(Frame* this, float qw, float qx, float qy, float qz) { - // Cache old quaternion in case new one is invalid - float oldQw=this->qw, oldQx=this->qx, oldQy=this->qy, oldQz=this->qz; - - float invLen = 1.0 / sqrt(qw*qw + qx*qx + qy*qy + qz*qz); - this->qw = qw * invLen; - this->qx = qx * invLen; - this->qy = qy * invLen; - this->qz = qz * invLen; - - if (Frame::IsValid(this) != 0) { - Frame::cache(this); // refresh l2gv matrix - } else { - // Restore — new quat had NaN - this->qw=oldQw; this->qx=oldQx; this->qy=oldQy; this->qz=oldQz; - } -} -``` - -### `Frame::set_heading` (FUN_00535E40) — line 320049 - -Sets heading from a yaw angle (degrees): - -``` -00535e40 void Frame::set_heading(Frame* this, float degrees) { - // BN noise computes a vector from an existing matrix column — irrelevant - double rad = degrees * 0.017453292519943295; // π/180 - float sinR = sin(rad); - float cosR = cos(rad); - Vector3 heading = { sinR, cosR, 0 }; // +Y is north; rotate CW - Frame::set_vector_heading(this, &heading); -} -``` - -### `Frame::set_vector_heading` (FUN_00535DB0) — line 320030 - -Sets heading to face a normalized 2D direction vector (rotation around Z-axis): - -``` -00535db0 void Frame::set_vector_heading(Frame* this, Vector3 const* dir) { - Vector3 d = *dir; - if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return; - // Note: AC's heading convention — angle from north (+Y) measured clockwise. - // 450 - atan2(x, y) normalizes to [0, 360). - double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323; - yawDeg = fmod(yawDeg, 360.0); - Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295); -} -``` - -### `Frame::rotate` (FUN_004525B0) — line 91477 - -Applies a small rotation increment in local space (used by omega integration): - -``` -004525b0 void Frame::rotate(Frame* this, Vector3 const* localOmegaTimesDt) { - // Lift the local-axis-angle vector to world by the current basis - Vector3 worldOmegaDt; - worldOmegaDt.x = this->m_fl2gv[0]*localOmegaTimesDt->x - + this->m_fl2gv[3]*localOmegaTimesDt->y - + this->m_fl2gv[6]*localOmegaTimesDt->z; - worldOmegaDt.y = this->m_fl2gv[1]*localOmegaTimesDt->x - + this->m_fl2gv[4]*localOmegaTimesDt->y - + this->m_fl2gv[7]*localOmegaTimesDt->z; - worldOmegaDt.z = this->m_fl2gv[2]*localOmegaTimesDt->x - + this->m_fl2gv[5]*localOmegaTimesDt->y - + this->m_fl2gv[8]*localOmegaTimesDt->z; - Frame::grotate(this, &worldOmegaDt); // global-frame rotation -} -``` - -### `Frame::set_origin` — does it exist? - -**No** — search of `acclient_2013_pseudo_c.txt` finds zero hits for `Frame::set_origin`. Origin is set by direct member assignment (`f.m_fOrigin = newOrigin`) or via `Frame::operator=`. Note: the named PDB does not list a public mutator for origin alone. - -### `Frame::is_zero` — does it exist? - -**No** — search returns zero hits for `Frame::is_zero`. The `is_zero` method exists on `AC1Legacy::Vector3` (e.g. `Vector3::is_zero(&this->m_velocityVector)` at line 283667), and is applied to `Frame::m_fOrigin` indirectly via `Vector3::operator==(&zeroVec, &frame.origin)`. - ---- - -## 12 — `Position::ctor`, `Position::distance`, `Position::get_offset` - -### `Position::Position` (FUN_00424AB0) — default ctor - -Sets vtable, zero objcell_id, identity Frame. - -### `Position::Position(Position*, uint32_t cellId, Frame*)` — line 91542 - -``` -00452780 void Position::Position(Position* this, uint32_t cellId, Frame* frame) { - this->vtable = 0x796910; - this->objcell_id = cellId; - Frame::operator=(&this->frame, frame); -} -``` - -### `Position::Position(Position*, Position const*)` — line 91655 (copy ctor) - -Just calls `Frame::operator=` on the embedded frame and copies cellId. - -### `Position::get_offset` — line 272088 - -**Cell-aware vector offset (this → arg3) in landblock-global coordinates.** - -``` -00509f60 Vector3* Position::get_offset(Position const* this, Vector3* out, Position const* other) { - Vector3 blockOffset; - // Compute the world offset between the two cell origins (uses landblock IDs). - LandDefs::get_block_offset(&blockOffset, this->objcell_id, other->objcell_id); - // out = (other.origin + blockOffset) - this.origin - out->x = (blockOffset.x + other->frame.origin.x) - this->frame.origin.x; - out->y = (blockOffset.y + other->frame.origin.y) - this->frame.origin.y; - out->z = (blockOffset.z + other->frame.origin.z) - this->frame.origin.z; - return out; -} -``` - -This is what acdream's `Position.GetOffset` mirrors. **Critical: cells in different landblocks must be reconciled via `LandDefs::get_block_offset` before subtracting origins.** - -### `Position::distance` (FUN_005A94B0) — line 438258 - -``` -005a94b0 Vector3* Position::distance(Position const* this, Position const* other) { - Vector3 r; - Position::get_offset(this, &r, other); - // (BN noise — actually returns sqrtf of the offset squared) - return r; // caller takes magnitude -} -``` - -Note: the BN decomp shows `result->z; result->y; result->x;` followed by `return result` — these dereferences load the floats but don't produce output here. The actual return value is the raw offset vector from `get_offset`; the caller computes `.Length()`. ACE's `Position.Distance` does this correctly. - ---- - -## 13 — Substepping algorithm summary (the key answer) - -``` -dt = currentTime - LastUpdateTime - -if (dt < EPSILON) return; // < 0.0002s — too small, defer -if (dt > HugeQuantum) return; // > 2.0s — stale, discard, advance update_time - -while (dt > MaxQuantum): // 0.1 s - PhysicsTimer.curr_time += MaxQuantum - UpdateObjectInternal(MaxQuantum) - dt -= MaxQuantum - -if (dt > MinQuantum): // 0.0333 s (1/30) - PhysicsTimer.curr_time += dt - UpdateObjectInternal(dt) // remainder, anywhere in (1/30, 0.1] - -LastUpdateTime = PhysicsTimer.curr_time -``` - -**Observations:** - -1. **Retail processes every frame**, not just every 30 Hz. The first guard is `EPSILON`, not `MinQuantum`. ACE flipped this to `< TickRate` (= 1/30) for server CPU savings — that's a divergence, not retail-faithful. -2. **dt below MinQuantum but above EPSILON → no simulation that frame, but `update_time` IS advanced** to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue. -3. **Between MinQuantum and MaxQuantum: a single substep** for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) **Actually at 60 fps the second guard (`> MinQuantum`) is also FALSE, so nothing runs.** This is consistent with retail running its physics tick at 30 Hz in `MainProc`. Retail's `MainProc` ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through. -4. **Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum.** This handles frame-stutter / lag spikes (e.g. world load). -5. **`process_hooks` runs once per substep** (inside `UpdatePositionInternal`), so its rate scales with substep count — important for FPHook fade timers. -6. **The collision sweep (`transition`) runs once per substep**, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes. - -### What this means for `LastUpdateTime` advancement - -After every substep loop, `LastUpdateTime = PhysicsTimer.curr_time`. `PhysicsTimer.curr_time` was incremented inside the loop by `MaxQuantum` per substep + the final remainder. **It accumulates ONLY consumed time** — if the early-`< EPSILON` guard fires, `update_time` is set to `Timer::cur_time` directly, dropping any unconsumed micro-fragment. - ---- - -## 14 — Cross-check against acdream's port - -**`PhysicsBody.update_object` (`src/AcDream.Core/Physics/PhysicsBody.cs:404-435`):** - -acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. **But it gates on `dt < MinQuantum` for the early return**, not `< EPSILON`. This matches ACE's port (which uses TickRate=1/30) but **diverges from retail**, which uses EPSILON=0.0002. - -**Practical effect of the divergence:** - -At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) → fail the remainder gate (0.0167 < MinQuantum=0.0333) → bump `update_time` and return. **No simulation ran, but time was consumed.** Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop. - -acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (`< MinQuantum` = 0.0333), returns immediately WITHOUT updating `LastUpdateTime`. **Next frame: dt = 0.0334 s**, passes the first gate, runs `UpdatePhysicsInternal(0.0334)` once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence. - -Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (`if dVar1 < MinQuantum → return`) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization. - -**`GameWindow.cs` per-tick remote motion path (line 6541-6553):** - -The comment "rely on `PhysicsBody.update_object` here — its MinQuantum 30 fps gate" is **accurate about acdream's port** but **slightly misleading about retail's behavior**. Retail does NOT have a 30 Hz gate in `update_object`; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch. - -**The real bug (Commit B fix at line 6190):** the env-var path was missing the `ResolveWithTransition` call (port of `find_transitional_position`) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail. - ---- - -## 15 — Open questions / follow-ups - -- **`Frame::set_origin`** and **`Frame::is_zero`** don't exist as named symbols. The conventions are: direct field write for origin, and `Vector3::is_zero` on `frame.m_fOrigin` for the test. Confirm acdream's port uses the same conventions (no need for these methods on the `Frame` type). -- **`update_object_server` does not exist.** ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one `update_object`. -- **The early-return gate divergence (EPSILON vs MinQuantum)** is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy. -- **`process_hooks` linked-list (`PhysicsObjHook`)** is not yet ported in acdream. acdream has anim-hook routing (`AnimationHookRouter`) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this. diff --git a/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md b/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md deleted file mode 100644 index 7da9316d..00000000 --- a/docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md +++ /dev/null @@ -1,526 +0,0 @@ -# 09 — CPartArray::Update + CSequence::update / update_internal / apply_physics / add_motion - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR -build, Binary Ninja pseudo-C with PDB names applied). - -> **PDB symbol-name collisions you will see in the pseudo-C below.** Several -> AnimSequenceNode getters were name-collapsed into mangled stubs. From the -> retail header `acclient.h` the struct is `{ CAnimation* anim; float framerate; -> int low_frame; int high_frame; }`. The Binary-Ninja pseudo-C shows them as: -> -> | Pseudo-C name | Real symbol | -> |-------------------------------------|------------------------------------------| -> | `MD_Data_Fade::GetDuration(node)` | `AnimSequenceNode::get_framerate(node)` | -> | `EffectInfoRegion::GetStat(node)` | `AnimSequenceNode::get_high_frame(node)` | -> | `Attribute2ndInfoRegion::GetStat(node)` | `AnimSequenceNode::get_low_frame(node)` | -> -> Verified by struct layout match (`framerate` is the first float field, -> `low_frame` then `high_frame`) and by the call-site context (these are read -> exactly where the per-keyframe loop needs the framerate, end frame, start -> frame). - ---- - -## 1. CPartArray::Update — `0x00517DB0` - -```cpp -// 00517db0 -void __thiscall CPartArray::Update(class CPartArray* this, float arg2, class Frame* arg3) -{ - // 00517dc2 - CSequence::update(&this->sequence, (double)arg2, arg3); -} -``` - -It is literally a one-line forwarder. **All animation behavior lives in -`CSequence`.** The caller is `CPhysicsObj::UpdatePositionInternal` at -`0x00512c95`, passing `dt` as `arg2` and a `Frame*` to receive root-motion -displacement (`pos_frames` deltas + `velocity*dt`). The same forwarder is also -called from `0x00513e97` inside `set_state_to_starting_frame` for placement. - -There is no per-part loop here — `CPartArray` keeps a single shared `CSequence` -that drives the root frame; per-part bone frames come from -`CSequence::get_curr_animframe()` later, applied in -`Frame::combine(this->parts[esi]->pos.frame, parentFrame, anim_frame->frame[i])` -inside `CPartArray::DoLocalToParent` (different code path). - ---- - -## 2. CSequence::update — `0x00525B80` - -```cpp -// 00525b80 -void __thiscall CSequence::update(class CSequence* this, double arg2 /*dt*/, class Frame* arg3) -{ - // 00525b88 - if (this->anim_list.head_ != nullptr) { - // 00525ba3 - CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3); - // 00525baa - CSequence::apricot(this); // garbage-collect drained link nodes - return; - } - - // No animations on the sequence — degenerate path: still integrate the - // raw velocity * dt onto the frame. (Used by physics-only objects with a - // CSequence carrying only velocity/omega and no animation list.) - // 00525bb9 - if (arg3 != nullptr) { - // 00525bca - CSequence::apply_physics(this, arg3, /*dt=*/arg2, /*sign=*/arg2); - } -} -``` - -Two key observations: - -1. The "no animations" branch (`anim_list.head_ == 0`) is the only path that - calls `apply_physics` with the **whole `dt`**. With animations present, the - per-keyframe path scales by `1/framerate` (see §4 below). -2. `apricot()` walks the doubly-linked list, deletes any link node prior to - `curr_anim` that has been fully drained, and trims the list down to the - cyclic head. This is how transition links (e.g. WalkForward → RunForward) - "fall off" once consumed. - ---- - -## 3. CSequence::update_internal — `0x005255D0` (the per-keyframe loop) - -This is the heart of retail animation. It is the function whose -literal-pseudocode reading is required for L.3 of the motion port. The -disassembly is x87-FPU-heavy with synthesised compare flags; the algorithm -below is the reconstructed control flow. - -### Signature - -```cpp -// 005255d0 -void __thiscall CSequence::update_internal( - class CSequence const* this, - double arg2, // dt (seconds) - class AnimSequenceNode** arg3, // in/out: &this->curr_anim - double* arg4, // in/out: &this->frame_number (fractional) - class Frame* arg5 // out: accumulator for root-motion displacement -); -``` - -### Algorithm (reconstructed) - -```cpp -while (true) { // 005255e8 - // -- per-tick framerate scaling ---------------------------------------- - framerate = AnimSequenceNode::get_framerate(*arg3); // 005255e8 (PDB-collision: MD_Data_Fade::GetDuration) - delta = framerate * arg2; // signed; negative = reverse play // 005255f1 - int start_frame_int = (int)floor(*arg4); // 00525607 - bool wrapped = false; // var_30_1 // 005255ff - int ebx = start_frame_int; // 0052561b - - *arg4 = *arg4 + delta; // advance fractional frame_number // 00525635 (fadd / fstp) - - if (delta >= 0.0) { // forward play branch // 00525646 - // ----- FORWARD -------------------------------------------------- - end_int = AnimSequenceNode::get_high_frame(*arg3); // 005257f5 (PDB-collision: EffectInfoRegion::GetStat) - if (*arg4 > (double)end_int) { // 00525806 - // overflowed past the cycle's end — clamp + record overflow time - float left_in_cycle = (float)end_int - (float)start_frame_int; // 0052580f / 00525817 - if (fabs(framerate) >= F_EPSILON) { // 00525841 - overflow_time = left_in_cycle / framerate; // 00525843 - // var_18_1 - } else { - overflow_time = 0.0; // 0052584d - } - *arg4 = (double)AnimSequenceNode::get_high_frame(*arg3); // 00525866 / fild / fstp - wrapped = true; // 0052586e (var_30_1 = 1) - } - - // -- per-keyframe loop: walk every integer frame just stepped on -- - // ebx is the frame we are LEAVING; it is decremented in the loop - // body BEFORE the iteration cmp. (Retail counts down because a - // forward step crossing N integer frames must apply the deltas in - // reverse: subtract1(prev_frame's posFrame) to back out, then - // combine(curr_frame's posFrame) to step in. See loop body.) - while ((double)ebx > *arg4 - 1.0) { /* equivalent to: while floor(*arg4) > ebx */ - if (arg5 != nullptr) { - node = *arg3; - if (node->anim->pos_frames != 0) { // 005258b1 - Frame::subtract1(arg5, arg5, - AnimSequenceNode::get_pos_frame(node, ebx)); // 005258be - } - if (fabs(framerate) >= F_EPSILON) { // 005258d3 - // apply_physics with dt = (1.0 / framerate) and same sign - // as outer dt — i.e. EXACTLY ONE keyframe duration of - // velocity*dt is integrated per crossed integer. - CSequence::apply_physics(this, arg5, - /*frame_dt =*/ 1.0 / framerate, // 005258d8 / 005258e1 - /*sign =*/ arg2); // 005258f8 - } - } - CSequence::execute_hooks(this, - AnimSequenceNode::get_part_frame(*arg3, ebx), - /*direction=*/ 0xFFFFFFFF); // 0052590c (-1 = forward) - ebx -= 1; // 00525916 - } - } else { // reverse play branch // 00525646 else (negative delta) - // ----- REVERSE -------------------------------------------------- - start_int = AnimSequenceNode::get_low_frame(*arg3); // 0052566a (PDB-collision: Attribute2ndInfoRegion::GetStat) - if (*arg4 <= (double)start_int - 1.0) { // 0052567b (fild + fsubr 1.0 + fcom 0.0) - float left_in_cycle = (float)start_int - (float)start_frame_int; - if (fabs(framerate) >= F_EPSILON) { - overflow_time = left_in_cycle / framerate; // 005256be - } else { - overflow_time = 0.0; // 005256c8 - } - *arg4 = (double)AnimSequenceNode::get_low_frame(*arg3); // 005256e1 - wrapped = true; // 005256e9 - } - - // -- per-keyframe loop: same idea, opposite direction ------------ - int ebx2 = ebx; /* var_2c_1 = ebx (saved at 0052561d) */ - while (/* floor(*arg4) < ebx2 */) { // 00525714 - if (arg5 != nullptr) { - node = *arg3; - if (node->anim->pos_frames != 0) { // 00525731 - Frame::combine(arg5, arg5, - AnimSequenceNode::get_pos_frame(node, ebx2)); // 0052573e - } - if (fabs(framerate) >= F_EPSILON) { // 00525753 - CSequence::apply_physics(this, arg5, - /*frame_dt=*/ 1.0 / framerate, // 00525758 / 00525761 - /*sign =*/ arg2); // 00525778 - } - } - CSequence::execute_hooks(this, - AnimSequenceNode::get_part_frame(*arg3, ebx2), - /*direction=*/ 1); // 0052578c (+1 = backward) - ebx2 += 1; // 00525796 - } - } - - // -- end-of-tick: did we wrap? ---------------------------------------- - if (!wrapped) return; // 00525943 / 005259ca - - // We hit the cycle boundary. Notify hook-target if we just consumed a - // non-cyclic link node, then advance to the next animation in the list. - if (this->hook_obj != nullptr) { // 0052594e - anim_list_head = ((char*)this->anim_list.head_) - 4; - if (anim_list_head != this->first_cyclic) { - CPhysicsObj::add_anim_hook(this->hook_obj, &anim_done_hook); // 00525968 - } - } - - // arg2 (the outer dt) is rewritten here to "overflow_time" so the loop - // reschedules from the new node. - CSequence::advance_to_next_animation(this, arg2 /*now overflow_time*/, - arg3, arg4, arg5); // 0052597d - arg2 = 0; *(arg2 + 4) = 0; /* reset outer dt accumulator */ // 0052598a / 0052598d - // …then `while (true)` again and run another iteration of the chain. -} -``` - -### What that means in plain English - -Per call to `update`: - -1. Take the current dt and multiply by `framerate` (signed, decompresses to - the dat's `AnimData::framerate * speedMod` per `operator*` at `00525d00`). -2. Add to `frame_number` (the fractional cursor). -3. Walk every integer keyframe boundary the cursor just crossed: - - **Forward** crossing: subtract the *just-left* keyframe's `pos_frame` - out of the displacement accumulator (it's already been baked in the - animation pose, so don't double-count it as root motion). Run hooks - with direction `-1`. - - **Reverse** crossing: combine (add) the *just-left* keyframe's - `pos_frame` back in. Run hooks with direction `+1`. - - In **both** directions, integrate `velocity * (1 / framerate) * sign(dt)` - onto the displacement frame via `apply_physics`. This is **one - keyframe's worth of velocity per crossed boundary** — not `velocity * dt`. -4. If the cursor went past the cycle's end (forward) or before its start - (reverse), clamp to the boundary, compute the leftover dt, advance to the - next AnimSequenceNode in the linked list, and loop again with the leftover. - ---- - -## 4. CSequence::apply_physics — `0x00524AB0` - -```cpp -// 00524ab0 -void __thiscall CSequence::apply_physics(class CSequence const* this, - class Frame* arg2 /*frame*/, - double arg3 /*frame_dt = 1.0/framerate*/, - double arg4 /*sign carrier = outer dt*/) -{ - // 00524ab7..00524ac1 - long double dt = fabs((long double)arg3); - - // 00524ac8..00524aca: if (sign(arg4) < 0) dt = -dt; - // I.e. the magnitude comes from arg3 (1/framerate, always positive), - // the sign comes from arg4 (outer dt). Reverse playback flips the sign. - if (arg4 < 0.0) dt = -dt; - - // 00524af1..00524b05: integrate world-space velocity onto the frame. - arg2->m_fOrigin.x += (float)(dt * (long double)this->velocity.x); - arg2->m_fOrigin.y += (float)(dt * (long double)this->velocity.y); - arg2->m_fOrigin.z += (float)(dt * (long double)this->velocity.z); - - // 00524b0f..00524b29: build (omega.x*dt, omega.y*dt, omega.z*dt) on stack. - Vector3 axis = { (float)(dt * (long double)this->omega.x), - (float)(dt * (long double)this->omega.y), - (float)(dt * (long double)this->omega.z) }; - // 00524b2d - Frame::rotate(arg2, &axis); -} -``` - -So `apply_physics` integrates **the CSequence's stored velocity/omega**, which -were set by `add_motion` (§5 below). The crucial structural detail repeated: -**`arg3` is `1.0 / framerate`, NOT `dt`.** `update_internal` calls -`apply_physics` once per crossed integer keyframe; over a full cycle this -sums to exactly `velocity * (cycle_duration_in_seconds)`. - ---- - -## 5. add_motion — `0x005224B0` (the velocity producer) - -`add_motion` is the writer that populates `CSequence::velocity` and -`CSequence::omega`. Called from the motion-table machinery -(`get_seq_animations` chain) when a new `MotionData` is enqueued. - -```cpp -// 005224b0 -void add_motion(class CSequence* arg1, class MotionData* arg2, float arg3 /*speedMod*/) -{ - if (arg2 == nullptr) return; - - // Velocity = MotionData.velocity * speedMod (componentwise) - // 005224d1..005224f8 - Vector3 v = { arg2->velocity.x * arg3, - arg2->velocity.y * arg3, - arg2->velocity.z * arg3 }; - CSequence::set_velocity(arg1, &v); - - // Omega = MotionData.omega * speedMod - // 0052250f..0052252f - Vector3 w = { arg2->omega.x * arg3, - arg2->omega.y * arg3, - arg2->omega.z * arg3 }; - CSequence::set_omega(arg1, &w); - - // For every AnimData in MotionData.anims, append it to the sequence - // with framerate scaled by speedMod (operator*(AnimData, float, AnimData) - // at 00525d00 builds the scaled copy: low/high frame copied verbatim, - // framerate multiplied, anim_id copied). - // 00522537..00522573 - for (int i = 0; i < arg2->num_anims; ++i) { - AnimData scaled; - operator*(&scaled, arg3, &arg2->anims[i]); - CSequence::append_animation(arg1, &scaled); - SetPositionStruct::~SetPositionStruct(&scaled); - } -} -``` - -`set_velocity` (0x00524880) and `set_omega` (0x005248A0) are 3-float -overwrites — **assignment, not accumulation**. So whenever a new MotionData -is added, prior velocity/omega are clobbered. - -(`combine_motion` at 0x00522580 / `subtract_motion` at 0x00522600 are the -accumulating variants — they call `combine_physics` / `subtract_physics` -instead — used elsewhere for additive blends.) - -### What this produces for Humanoid Walk/Run - -This is the dispositive answer to the L.3 mystery: - -> *"For Humanoid Walk/Run cycles where dat ships zero baked velocity, what -> does add_motion produce?"* - -**Zero.** `MotionData.velocity` for the Humanoid Walk and Run motion-table -entries is `(0,0,0)` (verified by acdream's own -`AnimationSequencer.SetCycle()` comment block at -`src/AcDream.Core/Physics/AnimationSequencer.cs:579-613`). `add_motion` -multiplies that zero by `speedMod` and writes zero into -`CSequence::velocity`. `apply_physics` therefore integrates zero translation -per keyframe step. **The dat-baked `pos_frames` array on each animation -also has zero translation per frame** for the Humanoid run cycle (cycles -in place — root motion is synthesised, not baked). - -So `update_internal`'s root-motion accumulator (`arg5`) ends the call -unchanged for a Humanoid run/walk cycle. **Retail does NOT produce body -translation from `CSequence::update`.** Body translation comes from a -SEPARATE source: `CMotionInterp::get_state_velocity` at `0x00528960`, -which returns `RunAnimSpeed × ForwardSpeed` (or `WalkAnimSpeed`, -`SidestepAnimSpeed`) as a hard-coded constant looked up from -`_DAT_007c96e0/e4/e8`. That value is fed into `CPhysicsObj::set_velocity` -upstream, then `CTransition::transitional_insert` integrates it across the -swept-sphere collision pipeline. - -For non-locomotion cycles (emotes, attacks, idle, jump): `MotionData.velocity` -may be non-zero (e.g. jump's vertical impulse) AND/OR the animation's -`pos_frames` array may contain baked deltas (e.g. attack lunges). Both -sources flow through the same `update_internal` loop above. - ---- - -## 6. CSequence::velocity / omega / framerate accessors - -There are no getter functions; the fields are read directly off the struct -(see `acclient.h` line 30751 / 30752 / 30754). For reference: - -```cpp -// 00524880 set_velocity — pure assignment of 3 floats -// 005248a0 set_omega — pure assignment of 3 floats -// 005248c0 combine_physics (additive: velocity += rhs, omega += rhs) -// 00524900 subtract_physics (additive: velocity -= rhs, omega -= rhs) -// 00524940 multiply_cyclic_animation_fr — for-each-cyclic-node node->framerate *= arg2 -// 00525be0 AnimSequenceNode::multiply_framerate — node.framerate *= arg2 (single) -``` - -`framerate` lives on each `AnimSequenceNode`, not on `CSequence`. It is -already pre-multiplied by `speedMod` at the time `add_motion` runs (via -the `operator*(AnimData, float)` constructor at `0x00525D00` — -`new_framerate = old_framerate * speedMod`). Negative speedMod produces -negative framerate, which `update_internal` reads as the reverse-play -branch — there is **no separate "play backward" flag**, just the sign of -`framerate`. - -`get_starting_frame` (`0x00525C80`) and `get_ending_frame` (`0x00525CB0`) -encode this: when `framerate < 0`, "start" returns `high_frame + 1` and -"end" returns `low_frame`; when `framerate >= 0`, "start" returns -`low_frame` and "end" returns `high_frame + 1`. This is why the -forward-vs-reverse branches in `update_internal` use opposite boundaries. - ---- - -## 7. Frame::combine / Frame::subtract1 — the per-keyframe pos_frame applicator - -`AnimSequenceNode::get_pos_frame(node, frame_index)` at `0x00525C10`: - -```cpp -class AFrame* get_pos_frame(int frame_index) { - CAnimation* anim = this->anim; - if (anim != nullptr && frame_index >= 0 && frame_index < anim->num_frames) - return ((AFrame*)((char*)anim->pos_frames + 0x1C * frame_index)); // sizeof(AFrame)=0x1C - return nullptr; -} -``` - -`Frame::combine(Frame* result, Frame const* lhs, AFrame const* rhs)` -at `0x00525180` (3-arg variant) — concatenates two transforms (`result = -lhs ∘ rhs`). `Frame::subtract1` at `0x00535520` is the inverse (`result = -lhs ∘ rhs⁻¹`). Both are pure 4×4-equivalent rigid-body composition; nothing -animation-specific. - ---- - -## 8. AnimSequenceNode::get_pos_frame — `0x00525C10` - -(See §7 — same function, used as a getter.) - ---- - -## 9. Per-keyframe loop summary (the answer to the L.3 critical question) - -> *"What does the per-keyframe loop look like exactly?"* - -Per crossed integer frame boundary (forward branch shown; reverse mirrors): - -```cpp -// 005258a5..00525917, per integer keyframe just crossed forward: -if (arg5 /*displacement frame*/) { - if (curr_node->anim->pos_frames) { - // Subtract the LEAVING keyframe's baked offset out of the running - // displacement accumulator. (We already advanced fractional frame - // past it; the pose for this frame is already where it should be in - // local space.) - Frame::subtract1(arg5, arg5, - AnimSequenceNode::get_pos_frame(curr_node, ebx)); - } - if (fabs(framerate) >= F_EPSILON) { - // Integrate exactly ONE keyframe-duration of CSequence::velocity - // and CSequence::omega onto arg5. - CSequence::apply_physics(this, arg5, 1.0 / framerate, sign_of(outer_dt)); - } -} -CSequence::execute_hooks(this, - AnimSequenceNode::get_part_frame(curr_node, ebx), -1); -ebx -= 1; // walk to next-older crossed frame -``` - -In plain English: **for every integer frame boundary the fractional cursor -just crossed, apply (velocity × keyframe_period) of "free" displacement and -also stitch baked posframe deltas to keep the cycle's per-frame in-place -loop registered.** Across a full cycle, the velocity contribution is -exactly `velocity * cycle_duration`. The framerate scaling is what keeps -the body moving the same world-space distance per real-time second -regardless of how the cycle is divided into frames. - ---- - -## 10. Cross-reference: acdream's port - -`src/AcDream.Core/Physics/AnimationSequencer.cs` (1455 lines): - -- **Per-keyframe loop is structurally correct** (lines 766–846): it walks - `lastFrame` across crossed integer boundaries, calls `ApplyPosFrame` - forward/reverse, fires hooks. Wrap-and-overflow logic mirrors retail's - `advance_to_next_animation`. -- **Critical gap — apply_physics is missing.** The retail per-keyframe loop - applies `CSequence::velocity * (1/framerate)` *in addition* to the - posFrame delta. acdream's `ApplyPosFrame` (line 1288) only applies the - `posFrames[frameIndex]` and **skips** the `apply_physics(velocity, 1/framerate)` - step. That's harmless for Humanoid run/walk because their dat velocity - is zero, but it is **wrong for any non-locomotion cycle that uses - `MotionData.velocity`** (jump impulse, knock-back, flying creatures). -- acdream adds a synth path (lines 614–650) that overwrites - `CSequence.CurrentVelocity` with `RunAnimSpeed * speedMod` for - locomotion cycles. That value is consumed externally by - `CMotionInterp.get_state_velocity` for body translation — which is - retail's actual locomotion path (see §5 closing note). So acdream's - end-to-end behavior matches retail for Humanoid locomotion *despite* the - missing `apply_physics` inside the sequencer, because both paths - bypass it. -- **Summary for L.3:** the sequencer's `apply_physics` integration is dead - code for Humanoid locomotion (dat velocity = 0). Porting it faithfully - is required for jump/emote/flying-creature root motion but does not - affect the run/walk-cycle bug L.3 is investigating. The bug must lie - upstream in `CMotionInterp::get_state_velocity` consumption or in the - per-tick path that re-feeds CSequence's velocity vs. the - `CTransition::transitional_insert` body sweep. - ---- - -## 11. Files touched / line citations - -All retail line numbers refer to -`docs/research/named-retail/acclient_2013_pseudo_c.txt`: - -| Function | Pseudo-C line | Address | -|-------------------------------------------|---------------|--------------| -| `CPartArray::Update` | 285883 | `0x00517DB0` | -| `CSequence::update` | 302402 | `0x00525B80` | -| `CSequence::update_internal` | 301839 | `0x005255D0` | -| `CSequence::advance_to_next_animation` | 301622 | `0x005252B0` | -| `CSequence::apply_physics` | 300955 | `0x00524AB0` | -| `CSequence::set_velocity` | 300798 | `0x00524880` | -| `CSequence::set_omega` | 300808 | `0x005248A0` | -| `CSequence::combine_physics` | 300818 | `0x005248C0` | -| `CSequence::subtract_physics` | 300832 | `0x00524900` | -| `CSequence::execute_hooks` | 300780 | `0x00524830` | -| `CSequence::apricot` | 300978 | `0x00524B40` | -| `add_motion` | 298437 | `0x005224B0` | -| `combine_motion` | 298472 | `0x00522580` | -| `subtract_motion` | 298492 | `0x00522600` | -| `AnimSequenceNode::get_pos_frame` (int) | 302447 | `0x00525C10` | -| `AnimSequenceNode::get_part_frame` | 302460 | `0x00525C40` | -| `AnimSequenceNode::get_starting_frame` | 302483 | `0x00525C80` | -| `AnimSequenceNode::get_ending_frame` | 302501 | `0x00525CB0` | -| `AnimSequenceNode::multiply_framerate` | 302425 | `0x00525BE0` | -| `operator*(AnimData, float, AnimData)` | 302531 | `0x00525D00` | -| Header struct `CSequence` | acclient.h:30747 | — | -| Header struct `AnimSequenceNode` | acclient.h:31063 | — | -| Header struct `CPartArray` | acclient.h:30762 | — | - -acdream files cross-referenced: - -- `src/AcDream.Core/Physics/AnimationSequencer.cs` (lines 570–650 synth-velocity, - 766–846 per-keyframe loop, 1236–1330 advance/posFrame application). diff --git a/docs/research/2026-05-04-l3-port/10-vector-update-jump.md b/docs/research/2026-05-04-l3-port/10-vector-update-jump.md deleted file mode 100644 index eb992f54..00000000 --- a/docs/research/2026-05-04-l3-port/10-vector-update-jump.md +++ /dev/null @@ -1,693 +0,0 @@ -# L.3 — VectorUpdate (0xF74E) handler chain + jump pseudocode - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named). - -All retail line numbers below refer to that file (`acclient_2013_pseudo_c.txt`). - ---- - -## 1. `CM_Physics::DispatchSB_VectorUpdate` — packet → handler - -**Address:** `0x006acd20` (line 692119) - -``` -006acd20 enum NetBlobProcessedStatus CM_Physics::DispatchSB_VectorUpdate( - class SmartBox* arg1, - class NetBlob* arg2) -{ - NetBlob* ebx = arg2; - if (ebx == 0 || arg1 == 0) return 3; - uint8_t* buf_ = ebx->buf_; // body bytes - uint32_t bufSize_ = ebx->bufSize_; - if (*(uint32_t*)buf_ != 0xf74e) return 3; // line 692130 — opcode gate - - uint32_t guid = *(uint32_t*)(buf_ + 4); // line 692136 — object id - arg2 = &buf_[8]; - - Vector3 velocity; // line 692139 - AC1Legacy::Vector3::UnPack(&velocity, &arg2, ...); - - Vector3 omega; // line 692141 - AC1Legacy::Vector3::UnPack(&omega, &arg2, ...); - - PhysicsTimestampPack ts; // line 692143 - PhysicsTimestampPack::UnPack(&ts, &arg2, ...); - - return SmartBox::HandleVectorUpdate(arg1, ebx, guid, &velocity, &omega, &ts); -} -``` - -Wire layout: `[opcode:u32 0xF74E][guid:u32][velocity:Vector3][omega:Vector3][ts:PhysicsTimestampPack]`. -The PhysicsTimestampPack carries `ts1` (port-event sequence) and `ts2` (vector -event sequence, used in DoVectorUpdate as `update_times[3]`). - ---- - -## 2. `SmartBox::HandleVectorUpdate` — sequence gate - -**Address:** `0x00453480` (line 92195) - -``` -00453480 enum NetBlobProcessedStatus SmartBox::HandleVectorUpdate( - SmartBox* this, - NetBlob* arg2, // raw blob (re-queued on early) - uint32_t arg3, // guid - Vector3 const* arg4, // velocity (world) - Vector3 const* arg5, // omega (world) - PhysicsTimestampPack const* arg6) -{ - int32_t ebp = arg6->ts2; // vector event ts (line 92199) - int32_t esi = arg6->ts1; // port event ts (line 92201) - CPhysicsObj* obj = CObjectMaint::GetObjectA(this->m_pObjMaint, arg3); - - if (obj != 0) - { - int32_t ebx = obj->update_times[8]; // last-seen port-event ts - // Signed compare across 16-bit wrap (lines 92210-92218 standard - // wrap-aware "newer?" macro): if ebx == esi → in sync, dispatch. - if (ebx == esi) - { - SmartBox::DoVectorUpdate(this, obj, arg4, arg5, ebp); - return 1; // PROCESSED - } - if (ebx != esi) - return 2; // OUT_OF_ORDER (newer port event hasn't arrived) - } - - // Object not yet known — queue the blob for later. - CObjectMaint::QueueBlobForObject(this->m_pObjMaint, arg3, arg2); - return 4; // DEFERRED -} -``` - -Note: VectorUpdate is gated against `update_times[8]` (the -**port-event timestamp**), not `update_times[3]`. This means a VectorUpdate -will *not* be applied unless the latest 0xF748 PortalCellUpdate / position -event has already been processed. This is how retail keeps the velocity -write coherent with the position the server intended. - ---- - -## 3. `SmartBox::DoVectorUpdate` — the actual write - -**Address:** `0x004521c0` (line 91208) - -``` -004521c0 void SmartBox::DoVectorUpdate( - SmartBox* this, - CPhysicsObj* arg2, - Vector3 const* arg3, // velocity (world) - Vector3 const* arg4, // omega (world) - uint16_t arg5) // ts2 (vector-event ts) -{ - int32_t esi = arg2->update_times[3]; // last vector ts on object - int32_t edi = arg5; - // Wrap-aware "edi newer than esi" check (lines 91217-91227). - // The decompiler's flag arithmetic collapses to 0 here; the real - // gate is `edi != esi`. - if (edi != esi) - { - arg2->update_times[3] = edi; // line 91229 - - if (arg2 != this->player) - { - CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91233 — REMOTE - CPhysicsObj::set_omega (arg2, arg4, 1); - } - else if (this->cmdinterp->vtable->UsePositionFromServer() != 0) - { - CPhysicsObj::set_velocity(arg2, arg3, 1); // line 91238 — LOCAL only when server-driven - CPhysicsObj::set_omega (arg2, arg4, 1); - } - } -} -``` - -**Critical observations** (relevant to acdream's K-fix15 question): - -1. **Retail does NOT set the Gravity flag here.** It does not touch - `state` at all. It only writes `m_velocityVector` (via set_velocity) - and `m_omegaVector` (via set_omega). Gravity is already-on for - creatures by default (set when the body enters the world via - `enter_default_state` → `LeaveGround`); VectorUpdate does not - need to re-set it. -2. **Retail does NOT clear Contact / OnWalkable here.** The - transient-state bits stay whatever they were. Clearing them is the - job of `set_on_walkable(false)` which fires from - `CMotionInterp::jump` (local jumps only), or from the next physics - tick when the sphere-sweep finds no contact plane. -3. **Retail just writes the velocity.** The next per-tick - `CPhysicsObj::UpdateObjectInternal` reads `m_velocityVector`, - adds gravity acceleration (computed by `calc_acceleration`), - integrates position, and the sphere-sweep handles contact. - -So acdream's `OnLiveVectorUpdated` (GameWindow.cs:3246-3304) is doing -*more* than retail: it also clears Contact/OnWalkable + sets Gravity -when v.Z > 0.5. The reason this is necessary in acdream is that -acdream's per-tick path (`UpdatePhysicsInternal`) gates gravity on -`!OnWalkable` rather than reading the `state.Gravity` bit — see -`PhysicsBody.calc_acceleration` and the per-tick velocity integration. -Retail's per-tick reads `state & GRAVITY` (always set for creatures) -AND `transient_state & (CONTACT | ON_WALKABLE)` to short-circuit -acceleration to zero (see calc_acceleration below). - -`set_velocity(arg3, /*arg3=*/1)` clamps |v| ≤ 50 (MaxVelocity) and -sets `transient_state.Active`. The `arg3=1` flag distinguishes -network-source (1) from local-source (0); only used to gate -update_time refresh. - ---- - -## 4. `CPhysicsObj::set_velocity` - -**Address:** `0x005113f0` (line 279361) - -``` -005113f0 void CPhysicsObj::set_velocity( - CPhysicsObj* this, Vector3 const* arg2, int32_t arg3) -{ - if (Vector3::operator!=(arg2, &this->m_velocityVector) != 0) // line 279364 - { - this->m_velocityVector = *arg2; // 279366-368 - // Magnitude clamp to 50 (MaxVelocity squared = 2500). - long double mag2 = vx*vx + vy*vy + vz*vz; // 279372 - if (50f*50f < mag2) // 279377 - { - AC1Legacy::Vector3::normalize(&this->m_velocityVector); // 279383 - this->m_velocityVector.x *= 50f; // 279384 - this->m_velocityVector.y *= 50f; - this->m_velocityVector.z *= 50f; - } - this->jumped_this_frame = 1; // 279389 ★ - } - - // Set Active transient flag (bit 7 = 0x80) when state.Gravity bit not set. - if ((this->state & 1) == 0) // line 279392 ★ - { - if (this->transient_state >= 0) - { - this->update_time = Timer::cur_time; // 279398-399 - } - this->transient_state |= 0x80; // 279402 — Active - } -} -``` - -Two side effects beyond the velocity write: -- **`jumped_this_frame = 1`** (offset +0x9C). Skips contact resolution - this frame; gives gravity room to lift the body off the floor before - the sweep clamps it back. Critical for jump start — without this, the - +Z velocity gets immediately killed by the floor sweep on frame N+1. -- **`transient_state |= Active (0x80)`**. Marks the object for - per-tick processing (UpdateObjectInternal early-outs if Active is - not set). - -Note line 279392: `(this->state & 1)` is testing bit 0, which is the -**Static** flag, not Gravity. Retail only blocks the Active bit-set -on static objects. - ---- - -## 5. `CPhysicsObj::set_local_velocity` - -**Address:** `0x005114d0` (line 279408) - -``` -005114d0 void CPhysicsObj::set_local_velocity( - CPhysicsObj* this, Vector3 const* arg2, int32_t arg3) -{ - // Multiply local-frame velocity by orientation matrix (frame.m_fl2gv, - // 9 floats at offset 0x...). worldX = m_fl2gv[0]*lx + m_fl2gv[3]*ly + m_fl2gv[6]*lz - Vector3 worldVel = orientation × arg2; - CPhysicsObj::set_velocity(this, &worldVel, arg3); -} -``` - -Used by `CMotionInterp::LeaveGround` to convert the body-local launch -vector (forward/right/up relative to facing) into a world vector. - ---- - -## 6. `OBJECTINFO::kill_velocity` (clear_velocity equivalent) - -**Address:** `0x0050cfe0` (line 274467) - -``` -0050cfe0 void OBJECTINFO::kill_velocity(OBJECTINFO* this) -{ - CPhysicsObj* obj = this->object; - Vector3 zero = (0, 0, 0); - CPhysicsObj::set_velocity(obj, &zero, 0); // arg3=0 (local source) -} -``` - -Retail does not have a separate `clear_velocity` — it just zeros via -set_velocity. Called from collision handlers when a wall hit kills -forward motion (lines 272567, 273237). - ---- - -## 7. `CMotionInterp::LeaveGround` — outbound jump trigger - -**Address:** `0x00528b00` (line 306022) - -``` -00528b00 void CMotionInterp::LeaveGround(CMotionInterp* this) -{ - if (!this->physics_obj) return; - - // Creature gate: only run for creatures (or when no weenie). - CWeenieObject* w = this->weenie_obj; - bool isCreature = w == 0 ? true : w->vtable->IsCreature(); - if (!(w == 0 || isCreature)) return; - - CPhysicsObj* po = this->physics_obj; - // Only if state.Gravity bit set (state & 4 — line 306037). - if ((po->state & 4) == 0) return; // ★ Gravity gate - - Vector3 leaveVel; // line 306039 - CMotionInterp::get_leave_ground_velocity(this, &leaveVel); - CPhysicsObj::set_local_velocity(po, &leaveVel, 1); // line 306041 - - this->standing_longjump = 0; // 306043 - this->jump_extent = 0f; // 306044 ★ extent reset - - CPhysicsObj::RemoveLinkAnimations(po); // 306045 - CMotionInterp::apply_current_movement(this, 0, 0); // 306046 -} -``` - -**Order matters:** `get_leave_ground_velocity` is called BEFORE -`jump_extent` is zeroed. After LeaveGround returns, calling -`get_jump_v_z()` returns 0 (because the extent gate at the top -fires). acdream's `PlayerMovementController` correctly captures -`jumpVz` before calling `LeaveGround` for that reason -(PlayerMovementController.cs:440). - -LeaveGround does NOT clear Contact / OnWalkable — that already -happened upstream in `CMotionInterp::jump` via -`CPhysicsObj::set_on_walkable(po, 0)` (line 305811). - ---- - -## 8. `CMotionInterp::HitGround` — landing trigger - -**Address:** `0x00528ac0` (line 305996) - -``` -00528ac0 void CMotionInterp::HitGround(CMotionInterp* this) -{ - if (!this->physics_obj) return; - - CWeenieObject* w = this->weenie_obj; - bool isCreature = w == 0 ? true : w->vtable->IsCreature(); - if (!(w == 0 || isCreature)) return; - - CPhysicsObj* po = this->physics_obj; - if ((po->state & 4) == 0) return; // Gravity gate - - CPhysicsObj::RemoveLinkAnimations(po); // 306013 - CMotionInterp::apply_current_movement(this, 0, 0); // 306014 — re-pose -} -``` - -**HitGround does NOT touch velocity.** It only clears the link-anim -and re-applies the current motion (which routes back through -`apply_current_movement → get_state_velocity → set_local_velocity`, -which writes the new ground velocity back to the body). - ---- - -## 9. `MovementManager::HitGround` / `LeaveGround` — wrappers - -**HitGround:** `0x00524300` (line 300425) -**LeaveGround:** `0x00524320` (line 300444) - -``` -00524300 void MovementManager::HitGround(MovementManager* this) -{ - if (this->motion_interpreter) - CMotionInterp::HitGround(this->motion_interpreter); - if (this->moveto_manager) - MoveToManager::HitGround(this->moveto_manager); -} -``` - -LeaveGround mirrors this. These are the public entry points; the -**caller is `CPhysicsObj::set_on_walkable`**. - ---- - -## 10. `CPhysicsObj::set_on_walkable` — the trigger source - -**Address:** `0x00511310` (line 279287) - -``` -00511310 void CPhysicsObj::set_on_walkable(CPhysicsObj* this, int32_t arg2) -{ - uint32_t ts = this->transient_state; - uint32_t newTs = (arg2 == 0) ? (ts & ~0x2) : (ts | 0x2); - this->transient_state = newTs; - - if ((ts & 0x2) == 0) // was NOT on-walkable - { - if (arg2 != 0) // → becoming on-walkable - { - if (this->movement_manager) - MovementManager::HitGround(this->movement_manager); // ★ LANDING - } - } - else if (arg2 == 0) // was on-walkable, becoming off - { - if (this->movement_manager) - { - MovementManager::LeaveGround(this->movement_manager); // ★ LAUNCH - CPhysicsObj::calc_acceleration(this); - return; - } - } - CPhysicsObj::calc_acceleration(this); -} -``` - -So the **edge detector** lives here: -- `OnWalkable: 0 → 1` fires `HitGround` (landing). -- `OnWalkable: 1 → 0` fires `LeaveGround` (launch). - -`set_on_walkable` itself is called from two places: - -1. **`CMotionInterp::jump`** (line 305811): `set_on_walkable(po, 0)` — - forces the launch edge. -2. **`CPhysicsObj::set_frame`** ground-floor sphere-sweep result - (lines 283474-283509). After the per-tick sphere-sweep stores its - resulting collision_info, this block walks contact_plane.N.z vs - `PhysicsGlobals::floor_z` (the cosine of max walkable slope, ≈ - 0.66 → 49°). If the contact plane is steep enough, `set_on_walkable(0)`; - if walkable, `set_on_walkable(1)`. **This is how landing detection - fires automatically in retail** — the per-tick sweep finds a walkable - surface under the body, set_on_walkable flips 0→1, HitGround fires. - -So the answer to "how does retail's HitGround fire" is: **from -`set_frame` (called by the per-tick sphere sweep)**, not from a -separate trigger. The sweep computes the new contact plane; if it -classifies as walkable, the edge fires HitGround. - ---- - -## 11. `CMotionInterp::jump` — the entry point - -**Address:** `0x00528780` (line 305792) - -``` -00528780 uint32_t CMotionInterp::jump(CMotionInterp* this, float arg2, int32_t* arg3) -{ - CPhysicsObj* po = this->physics_obj; - if (po == 0) return 8; - - CPhysicsObj::interrupt_current_movement(po); // 305800 - uint32_t result = CMotionInterp::jump_is_allowed(this, arg2, arg3); - if (result != 0) { - this->standing_longjump = 0; // 305805 - return result; // failure code - } - - this->jump_extent = arg2; // 305810 ★ extent - CPhysicsObj::set_on_walkable(po, 0); // 305811 ★ launch - return 0; -} -``` - -The launch sequence is: -1. `jump_is_allowed` validates (returns 0 on success). -2. Store extent. -3. `set_on_walkable(false)` → triggers `LeaveGround` via the wrapper - chain in step 10 above. LeaveGround reads `jump_extent` to compose - `get_leave_ground_velocity` and writes it via `set_local_velocity`. -4. Returns to caller (PlayerInputControl::Event_Jump_NonAutonomous, - line 376264) which sends 0xF61C MoveToState + 0xF74E VectorUpdate - (via Event_Jump → JumpPack). - ---- - -## 12. `CMotionInterp::get_jump_v_z` — vertical component - -**Address:** `0x00527aa0` (line 304953) - -``` -00527aa0 float CMotionInterp::get_jump_v_z(CMotionInterp const* this) -{ - float extent = this->jump_extent; // 304957 - if (extent < 0.000199999995f) return 0.0f; // 304959 (epsilon) - if (extent > 1.0f) extent = 1.0f; // 304968-973 clamp - - CWeenieObject* w = this->weenie_obj; // 304975 - if (w == 0) return /* fallback */; // returns last-result reg (10.0f from DAT) - - return w->vtable->InqJumpVelocity(extent, ...); // 304980 -} -``` - -`InqJumpVelocity` is the per-character jump-skill curve. Returns -the actual launch v_z in m/s (e.g. extent=1.0 with jump-skill 300 -→ ~7.8 m/s peak from PlayerWeenie). - ---- - -## 13. `CMotionInterp::get_leave_ground_velocity` — full launch vector - -**Address:** `0x005280c0` (line 305404) - -``` -005280c0 void CMotionInterp::get_leave_ground_velocity( - CMotionInterp* this, Vector3* arg2) -{ - CMotionInterp::get_state_velocity(this, arg2); // 305408 — XY (body-local) - arg2->z = CMotionInterp::get_jump_v_z(this); // 305409+305411 - - // If all three components are |x| < 0.0002 (near-zero): - if (|arg2->x| < eps && |arg2->y| < eps && |arg2->z| < eps) - { - // Project current world velocity into body-local frame via the - // transposed orientation matrix m_fl2gv. - // (lines 305434-305440) - arg2->x = m_fl2gv[0]*velX + m_fl2gv[1]*velY + m_fl2gv[2]*velZ; - arg2->y = m_fl2gv[3]*velX + m_fl2gv[4]*velY + m_fl2gv[5]*velZ; - arg2->z = m_fl2gv[6]*velX + m_fl2gv[7]*velY + m_fl2gv[8]*velZ; - } -} -``` - -The fast-path is the common case: `get_state_velocity` writes (X=strafe, -Y=forward, Z=0); then we overwrite Z with v_z. The fallback only fires -when state-velocity AND jump_v_z are all near zero (e.g. zero-extent -jump while standing still); it preserves residual world velocity. - ---- - -## 14. `CMotionInterp::jump_is_allowed` - -**Address:** `0x005282b0` (line 305509) - -``` -005282b0 uint32_t CMotionInterp::jump_is_allowed( - CMotionInterp* this, float arg2, int32_t* arg3) -{ - CPhysicsObj* po = this->physics_obj; - if (po == 0) return 0x24; // (no obj) GeneralFail - - // Creature path: - CWeenieObject* w = this->weenie_obj; - bool wIsCreature = w ? w->vtable->IsCreature() : true; - if (w != 0 && !wIsCreature) - { - // Non-creature — always allowed-ish, fall through to weenie checks. - goto label_5282f6; - } - - // Creature: require Gravity flag set AND grounded (Contact + OnWalkable). - if ((po->state & 4) == 0) return 0x24; // 305561 — no gravity, can't jump - uint8_t ts = po->transient_state; - if (!((ts & 0x1) && (ts & 0x2))) return 0x24; // 305566 — not grounded → 0x24 - -label_5282f6: - if (CPhysicsObj::IsFullyConstrained(po)) return 0x47; // 305524 - - // Pending-queue check. - LListData* head = this->pending_motions.head_; - uint32_t pendingErr = head ? head->jumpErr : 0; - if (head == 0 || pendingErr == 0) - { - pendingErr = CMotionInterp::jump_charge_is_allowed(this); - if (pendingErr == 0) - { - uint32_t mErr = CMotionInterp::motion_allows_jump(this, - this->interpreted_state.forward_command); - if (mErr != 0) return mErr; - if (this->weenie_obj == 0) return mErr; // 0 - // Stamina cost check. - if (w->vtable->JumpStaminaCost(arg2, arg3) != 0) - return 0; - return 0x47; // not enough stamina - } - } - return pendingErr; -} -``` - -Error codes: -- `0x00` = success -- `0x24` = `CantJumpInAir` / general motion failure -- `0x47` = `CantJumpFromPosition` (constrained, weenie-blocked, or stamina) -- `0x48` = `CantJumpFromMotion` (motion command blocks jump — emote, etc.) -- `0x49` = weenie-blocked (load-down) - ---- - -## 15. `CMotionInterp::contact_allows_move` - -**Address:** `0x00528240` (line 305471) - -``` -00528240 int32_t CMotionInterp::contact_allows_move( - CMotionInterp const* this, uint32_t arg2) -{ - if (this->physics_obj == 0) return 0; - // Always allow these "anchor" commands regardless of grounding: - if (arg2 == 0x40000015 || arg2 == 0x40000011) return 1; // (305481-482) - if (arg2 >= 0x6500000d && arg2 <= 0x6500000e) return 1; // (305478) — sidestep cmds - // Non-creature → allow. - CWeenieObject* w = this->weenie_obj; - if (w != 0 && !w->vtable->IsCreature()) return 1; // (305490) - // No gravity → allow. - if ((this->physics_obj->state & 4) == 0) return 1; // (305495) - // Grounded (Contact + OnWalkable) → allow. - uint8_t ts = this->physics_obj->transient_state; - if ((ts & 0x1) && (ts & 0x2)) return 1; // (305500) - return 0; -} -``` - -This is the per-command grounding check. Walk/run only succeed when -grounded; emotes/sidesteps always succeed. - ---- - -## 16. `CPhysicsObj::calc_acceleration` — gravity application - -**Address:** `0x00510950` (line 278533) - -``` -00510950 void CPhysicsObj::calc_acceleration(CPhysicsObj* this) -{ - uint8_t ts = this->transient_state; - // Grounded: zero accel (and zero omega). Tests Contact && OnWalkable - // && (state & "Hooked-from-floor" bit). Real semantics: grounded creature - // gets no gravity acceleration. - if ((ts & 1) && (ts & 2) && (this->state & 0x???) == 0) - { - accel = (0, 0, 0); - omega = (0, 0, 0); - return; - } - // Gravity flag NOT set: zero accel. - if ((this->state & 4) == 0) // line 278549 — Gravity bit (state & 4) - { - accel = (0, 0, 0); - return; - } - // Airborne with gravity: apply downward gravity. - accel = (0, 0, PhysicsGlobals::gravity); // line 278559 (gravity = -9.8 m/s²) -} -``` - -So the **state.Gravity bit (0x4)** is what enables gravity application -in retail. It's set when the creature enters the world and stays set; -it does NOT need to be re-set on each jump. Acdream's `OnLiveVectorUpdated` -explicitly setting `State |= Gravity` is defensive — if your remote-tracking -code preserved the bit from the moment the body was created, this would -be a no-op. - ---- - -## 17. State writes during a complete jump arc — answers to the brief - -| Phase | What happens | Who writes what | -|---|---|---| -| **Pre-jump** | Standing, OnWalkable=1, Contact=1, Gravity=set, vel=0 | initial state | -| **Player jump start** | `MotionInterp::jump(extent)` succeeds | `jump_extent = extent`; `set_on_walkable(0)` (clears OnWalkable=2 bit) | -| ↳ via set_on_walkable | edge 1→0 fires `MovementManager::LeaveGround` | TransientState OnWalkable bit cleared; calc_acceleration recalcs (now (0,0,-9.8) since !grounded) | -| ↳ via LeaveGround | computes launch vector | `set_local_velocity(leaveVel)` → `set_velocity(worldVel)` writes m_velocityVector + sets Active + sets `jumped_this_frame=1`; clears `jump_extent=0` | -| **Per-tick airborne** | UpdateObjectInternal | reads m_velocityVector, integrates pos += vel·dt + ½·accel·dt²; vel.Z += accel.Z·dt | -| **Mid-arc** | sphere-sweep finds no walkable plane | OnWalkable stays 0 | -| **Server VectorUpdate** | `0xF74E` arrives | Only `m_velocityVector` + `m_omegaVector` overwritten. Contact/OnWalkable/Gravity untouched. | -| **Landing (sweep finds floor)** | `set_frame` block 283474-283509 reclassifies contact_plane | If N.z ≥ floor_z: `set_on_walkable(true)` | -| ↳ edge 0→1 | fires `MovementManager::HitGround` | TransientState OnWalkable bit set; calc_acceleration → (0,0,0); HitGround calls RemoveLinkAnimations + apply_current_movement (which re-poses + writes new ground velocity via get_state_velocity → set_local_velocity) | - -**Mid-arc UPs from server:** retail does NOT have a separate "snap" or -"integrate" path. A server PortalCellUpdate (0xF748) during flight will -just write the new position via `CPhysicsObj::SetPositionInternal` (same -sequence-gate logic in `CObjectMaint::QueuePortalCellUpdate`), and the -sphere-sweep that next tick decides if landing happened. The arc is -purely client-integrated; server position events override it -authoritatively when they arrive. - -**Cycle transition Falling → Ready/Walk/Run on landing:** retail does NOT -explicitly transition. `HitGround` calls `apply_current_movement` which -re-pushes the existing `interpreted_state.forward_command`. If the player -is still holding W, that's RunForward, and the cycle naturally reverts. -The Falling animation is layered as a link-animation that -`RemoveLinkAnimations` clears at HitGround. (acdream's -`SetCycle(landingCmd)` in GameWindow.cs:3487 is a more explicit -re-pose; equivalent effect.) - ---- - -## 18. Cross-check vs acdream - -| acdream method | retail counterpart | Match? | -|---|---|---| -| `MotionInterpreter.jump` (line 691) | `CMotionInterp::jump` 0x00528780 | ✅ matches: jump_is_allowed, JumpExtent, set_on_walkable(false) | -| `MotionInterpreter.get_jump_v_z` (722) | 0x00527aa0 | ✅ matches: epsilon gate, clamp, weenie call | -| `MotionInterpreter.get_leave_ground_velocity` (759) | 0x005280c0 | ✅ matches incl. fallback projection | -| `MotionInterpreter.LeaveGround` (901) | 0x00528b00 | ✅ matches order (vel-before-extent-reset) | -| `MotionInterpreter.HitGround` (924) | 0x00528ac0 | ✅ matches | -| `MotionInterpreter.jump_is_allowed` | 0x005282b0 | ✅ matches | -| `MotionInterpreter.contact_allows_move` | 0x00528240 | ✅ matches | -| `PhysicsBody.set_velocity` (206) | 0x005113f0 | ⚠️ **MISSING `jumped_this_frame = 1`** + missing transient_state.Active gate on `state & 1` (Static) | -| `PhysicsBody.set_local_velocity` (236) | 0x005114d0 | ✅ matches (Quaternion equivalent of matrix multiply) | -| `GameWindow.OnLiveVectorUpdated` (3246) | `SmartBox::DoVectorUpdate` 0x004521c0 | ⚠️ **acdream sets Gravity / clears Contact+OnWalkable; retail does NOT.** Acdream's behavior is defensive; retail relies on the bits already being correct from launch. | - -### Issues / divergences worth filing - -1. **`PhysicsBody.set_velocity` is missing `jumped_this_frame = 1`.** - Without this flag the next-tick collision sweep clamps the +Z - velocity to zero before gravity can lift the body. Effective bug: - first-frame after launch the body may be re-clamped to floor. - Acdream may be papering over this elsewhere (e.g. by deferring - sweep until N+2) — worth verifying whether per-tick code reads a - `JumpedThisFrame` member. - -2. **Acdream `OnLiveVectorUpdated` extra writes vs retail.** Acdream - sets `State |= Gravity` and clears Contact+OnWalkable when v.Z>0.5. - Retail's `SmartBox::DoVectorUpdate` does only set_velocity + set_omega. - The reason acdream needs the extra writes is that acdream's per-tick - integrator gates gravity on `!OnWalkable` instead of `state & Gravity`. - If we ported `calc_acceleration` faithfully (state.Gravity bit set - at body creation, persists across jumps), the OnLiveVectorUpdated - bit-setting would become unnecessary. - -3. **K-fix15 question (airborne + IsOnGround + Velocity.Z<=0):** retail's - landing detection has nothing to do with Velocity.Z. It uses the - sphere-sweep's contact plane (per `CPhysicsObj::set_frame` - collision_info path) and compares `contact_plane.N.z` against - `floor_z`. Acdream's velocity-Z-based landing heuristic in - `OnLivePositionUpdated` is a pragmatic shortcut (we don't have a - full sphere-sweep on remotes), but is materially divergent from - retail. Long-term, when remote sphere-sweep lands, swap to the - N.z gate. - ---- - -## File written - -`docs/research/2026-05-04-l3-port/10-vector-update-jump.md` diff --git a/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md b/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md deleted file mode 100644 index dbb55dcb..00000000 --- a/docs/research/2026-05-04-l3-port/11-um-dispatch-deep.md +++ /dev/null @@ -1,1029 +0,0 @@ -# L.3 port — per-axis UM dispatch DEEP DIVE - -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` -(Sept 2013 EoR build). All line numbers cite that file. - -This document picks up where `02-um-handling.md` left off: it goes -DEEPER into the per-axis dispatch chain that fires on each UM (and -on every per-tick re-application). It is the canonical reference for -"when an UpdateMotion arrives with forward+sidestep+turn populated, -what does retail call, in what order, and what does it write?" - ---- - -## 0. Ten-second summary - -`MovementManager::unpack_movement` (300563) reads movement_type=0, -calls `InterpretedMotionState::UnPack` (294360, see §5 of doc 02), then -calls `MovementManager::move_to_interpreted_state` (300259) which -delegates to **`CMotionInterp::move_to_interpreted_state`** (305936). - -That function does TWO things: - -1. **`InterpretedMotionState::copy_movement_from`** — bulk-overwrite - all 7 fields of the body's InterpretedState with the wire values - (current_style, fwd_cmd, fwd_speed, side_cmd, side_speed, turn_cmd, - turn_speed). Unconditional. No diffing. -2. **`apply_current_movement(cancelMoveTo=1, allowJump=…)`** (305838), - which routes to either `apply_raw_movement` (autonomous local - player) or **`apply_interpreted_movement`** (305713) — the latter - is what fires for remote observers. - -`apply_interpreted_movement` is the **per-axis dispatcher**. It calls -`DoInterpretedMotion` (305575) for each of: current_style, forward -axis, sidestep axis (or stop), turn axis (or stop). Each call ends in -`CPhysicsObj::DoInterpretedMotion` → `set_local_velocity(get_state_velocity())`. - -**The "staircase" bug acdream's env-var path showed = absence of this -per-axis dispatch on the per-tick remote driver.** The default acdream -path runs `apply_current_movement` (correct) on UM intake, but the -env-var experimental path bypassed it and only ran the manager's -position lerp — so velocity was never re-derived, and Z stayed -flat between UMs even when the wire said "running up a slope." - ---- - -## 1. CMotionInterp::PerformMovement (the top-level command pump) - -**Function:** `CMotionInterp::PerformMovement` -**Address:** `0x00528e80` -**Lines:** 306221–306268. - -```c -00528e80 uint32_t PerformMovement(this, MovementStruct const* arg2) -{ - int32_t ecx_1 = (arg2->type - 1); - if (ecx_1 > 4) return 0x47; // BadMovementType - switch (ecx_1) { - case 0: /* DoMotion */ - uint32_t eax = DoMotion(this, arg2->motion, arg2->params); - CPhysicsObj::CheckForCompletedMotions(this->physics_obj); - return eax; - case 1: /* DoInterpretedMotion */ - uint32_t eax_2 = DoInterpretedMotion(this, arg2->motion, arg2->params); - CPhysicsObj::CheckForCompletedMotions(this->physics_obj); - return eax_2; - case 2: /* StopMotion */ - uint32_t eax_4 = StopMotion(this, arg2->motion, arg2->params); - CPhysicsObj::CheckForCompletedMotions(this->physics_obj); - return eax_4; - case 3: /* StopInterpretedMotion */ - uint32_t eax_6 = StopInterpretedMotion(this, arg2->motion, arg2->params); - CPhysicsObj::CheckForCompletedMotions(this->physics_obj); - return eax_6; - case 4: /* StopCompletely */ - StopCompletely(this); - CPhysicsObj::CheckForCompletedMotions(this->physics_obj); - return 0; - } -} -``` - -**Behavior:** dispatch by `MovementStruct::type` (1..5): - -| type | meaning | handler | -|---|---|---| -| 1 | DoMotion (raw, with adjust_motion) | DoMotion (306159) | -| 2 | DoInterpretedMotion (already adjusted) | DoInterpretedMotion (305575) | -| 3 | StopMotion (raw stop) | StopMotion (305674) | -| 4 | StopInterpretedMotion (interpreted stop) | StopInterpretedMotion (305635) | -| 5 | StopCompletely | StopCompletely (305208) | - -The wrapping `MovementManager::PerformMovement` (300194) chooses -between this command pump (types 1–5 → `CMotionInterp`) and the -MoveTo manager (types 6–9 → `MoveToManager`). **Inbound 0xF74C -RawCommand packets do NOT call PerformMovement** — they call -`move_to_interpreted_state` directly. PerformMovement is the API for -LOCAL command sources (CommandInterpreter, MoveToManager re-emits, -slash commands). - ---- - -## 2. CMotionInterp::DoMotion (the raw command path) - -**Function:** `CMotionInterp::DoMotion` -**Address:** `0x00528d20` -**Lines:** 306159–306217. - -```c -00528d20 uint32_t DoMotion(this, uint32_t motion, MovementParameters const* params) -{ - if (physics_obj == 0) return 8; - - uint32_t ebp = motion; // saved unmodified - - if ((params->__inner0.byte1 & 0x80) != 0) // CancelMoveTo bit - CPhysicsObj::interrupt_current_movement(physics_obj); - - if ((params->__inner0.byte1 & 0x08) != 0) // SetHoldKey bit - SetHoldKey(this, params->hold_key_to_apply, ((params->__inner0 >> 0xf) & 1)); - - adjust_motion(this, &motion, &speed, params->hold_key_to_apply); - - // Combat-style guards on RAW (pre-adjust) motion - if (this->interpreted_state.current_style != 0x8000003d /*NonCombat*/) { - if (ebp == 0x41000012) return 0x3f; // CantCrouchInCombat - if (ebp == 0x41000013) return 0x40; // CantSitInCombat - if (ebp == 0x41000014) return 0x41; // CantSleepInCombat - if ((ebp & 0x02000000) != 0) return 0x42; // CantChatEmoteInCombat - } - - // Action quota check on RAW (pre-adjust) motion - if ((ebp & 0x10000000) != 0 - && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6) - return 0x45; // TooManyActions - - uint32_t result = DoInterpretedMotion(this, motion /*adjusted*/, &var_2c); - - if (result == 0 && (params->__inner0.byte1 & 0x20) != 0) // ModifyRawState bit - RawMotionState::ApplyMotion(&this->raw_state, ebp /*pre-adjust*/, params); - - return result; -} -``` - -**Per-axis behavior:** DoMotion is a **single-axis** entry. It dispatches -ONE motion (passed as `arg2`) through `adjust_motion` → DoInterpretedMotion. -For multi-axis local input, the caller fires DoMotion three times -(once for forward, once for side, once for turn). Acdream's -`MotionInterpreter.DoMotion` (l.381) is structurally close BUT lacks -the `adjust_motion` call — meaning local `DoMotion(WalkBackward, +1.0)` -never gets sign-flipped to `WalkForward + -0.65`. (See §13 below.) - -The **RawState.ApplyMotion uses the PRE-adjust `ebp`**, the -InterpretedState path inside DoInterpretedMotion uses the post-adjust -`motion`. This is what lets the wire carry both "user said walk -backward" (raw) and "physics ran walk forward at -0.65" (interpreted) -on the same packet. - ---- - -## 3. CMotionInterp::adjust_motion (the canonical sign-flipper) - -**Function:** `CMotionInterp::adjust_motion` -**Address:** `0x00528010` -**Lines:** 305343–305400. - -Already documented in detail in `02-um-handling.md` §10. Repeating the -mappings table here for reference: - -| Input | Speed | Output | Speed | -|---|---|---|---| -| WalkForward (0x45000005) | s | WalkForward | s | -| WalkBackward (0x45000006) | s | WalkForward | -0.65 × s | -| SideStepLeft (0x6500000e) | s | SideStepRight | -s | -| ??? (0x65000010) alias | s | SideStepRight | -s | -| SideStepRight (0x6500000f) | s | SideStepRight | 1.248 × s | -| RunForward (0x44000007) | s | RunForward | s (no change here) | - -Then if `holdKey == HoldKey_Run`: **`apply_run_to_command(this, &motion, &speed)`** runs. - ---- - -## 4. CMotionInterp::apply_run_to_command (HoldKey rewriter) - -**Function:** `CMotionInterp::apply_run_to_command` -**Address:** `0x00527be0` -**Lines:** 305062–305123. - -Documented in `02-um-handling.md` §11. Key facts: - -- WalkForward + speed > 0 → **promotes to RunForward**, multiplies speed by speedMod (runRate). -- WalkForward + speed ≤ 0 → stays WalkForward, multiplies speed by speedMod (so backward stays backward but at run-pace × 0.65). -- TurnRight → speed *= 1.5 (RunTurnFactor). -- SideStepRight → speed *= speedMod, clamp |result| ≤ 3.0 (MaxSidestepAnimRate). - ---- - -## 5. CMotionInterp::DoInterpretedMotion (the leaf executor) - -**Function:** `CMotionInterp::DoInterpretedMotion` -**Address:** `0x00528360` -**Lines:** 305575–305631. - -```c -00528360 uint32_t DoInterpretedMotion(this, motion, params) -{ - if (physics_obj == 0) return 8; - uint32_t result; - - if (contact_allows_move(this, motion) != 0) { - if (this->standing_longjump != 0 - && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)) - { - // mid-longjump: skip engine-side action, just touch InterpretedState if asked - if (params->__inner0.byte1 & 0x40 /*ModifyInterpretedState*/) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - result = 0; - } - else { - if (motion == 0x40000011 /*Dead*/) - CPhysicsObj::RemoveLinkAnimations(this->physics_obj); - - // The HOT call: pushes velocity through CPhysicsObj - result = CPhysicsObj::DoInterpretedMotion(this->physics_obj, motion, params); - if (result == 0) { - uint32_t jumpErr; - if ((params->__inner0 & 0x20000) == 0) { - jumpErr = motion_allows_jump(this, motion); - if (jumpErr == 0 && (motion & 0x10000000) == 0) - jumpErr = motion_allows_jump(this, this->interpreted_state.forward_command); - } else { - jumpErr = 0x48; /*disable*/ - } - add_to_queue(this, params->context_id, motion, jumpErr); - if (params->__inner0.byte1 & 0x40) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - } - } - } - else if ((motion & 0x10000000 /*Action*/) == 0) { - // Airborne but motion isn't an Action — just touch InterpretedState if asked - if (params->__inner0.byte1 & 0x40) - InterpretedMotionState::ApplyMotion(&this->interpreted_state, motion, params); - result = 0; - } - else result = 0x24 /*YouCantJumpWhileInTheAir*/; - - if (physics_obj != 0 && physics_obj->cell == 0) - CPhysicsObj::RemoveLinkAnimations(physics_obj); - - return result; -} -``` - -**This is the function `apply_interpreted_movement` calls 3× per UM** -(once each for forward/sidestep/turn axes). The actual physics velocity -push is inside `CPhysicsObj::DoInterpretedMotion` — that's where -`set_local_velocity(get_state_velocity(...))` happens, AND that's where -the animation sequencer is driven by `set_motion_table_data`. - -**Critical:** when called from `apply_interpreted_movement`, `params` -is a fresh `MovementParameters()` with default flags — and the default -has **`ModifyInterpretedState = 0`**. So per-axis re-application is -purely a PHYSICS push; the state itself stays as `copy_movement_from` -bulk-loaded it. - ---- - -## 6. CMotionInterp::apply_interpreted_movement (THE per-axis dispatcher) - -**Function:** `CMotionInterp::apply_interpreted_movement` -**Address:** `0x00528600` -**Lines:** 305713–305788. - -This is the heart of the per-axis dispatch. **Every UM that reaches -`move_to_interpreted_state` ends here**, and so does every state-change -that goes through `apply_current_movement` (e.g. landing, leave-ground, -SetWeenieObject, SetPhysicsObject, ReportExhaustion). - -```c -00528600 void apply_interpreted_movement(this, int32_t arg2 /*cancelMoveTo*/, int32_t arg3 /*allowJump*/) -{ - if (physics_obj == 0) return; - - MovementParameters var_2c; - MovementParameters::MovementParameters(&var_2c); // default flags: NO ModifyState, NO ModifyRaw - - // (1) Cache MyRunRate from forward_speed if we are in run state - if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) - this->my_run_rate = this->interpreted_state.forward_speed; - - // (2) STYLE axis — re-apply current_style as a motion (combat ↔ peace, etc) - DoInterpretedMotion(this, this->interpreted_state.current_style, &var_2c); - - // (3) FORWARD axis (with airborne / longjump branches) - if (contact_allows_move(this, this->interpreted_state.forward_command) == 0) - { - // Airborne / dead — force Falling animation - DoInterpretedMotion(this, 0x40000015 /*Falling*/, &var_2c); - } - else if (this->standing_longjump != 0) - { - // In a charged longjump — pin forward to Ready, kill any sidestep - DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); - StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); - } - else - { - // (3a) FORWARD: dispatch the wire-supplied forward command verbatim - var_2c.speed = this->interpreted_state.forward_speed; - DoInterpretedMotion(this, this->interpreted_state.forward_command, &var_2c); - - // (3b) SIDESTEP: dispatch OR explicit-stop - if (this->interpreted_state.sidestep_command == 0) - StopInterpretedMotion(this, 0x6500000f /*SideStepRight*/, &var_2c); - else { - var_2c.speed = this->interpreted_state.sidestep_speed; - DoInterpretedMotion(this, this->interpreted_state.sidestep_command, &var_2c); - } - } - - // (4) TURN axis: dispatch OR explicit-stop - uint32_t turn_command = this->interpreted_state.turn_command; - if (turn_command != 0) - { - var_2c.speed = this->interpreted_state.turn_speed; - DoInterpretedMotion(this, turn_command, &var_2c); - return; - } - // turn_command == 0 → explicit stop on TurnRight, plus a Ready add_to_queue - uint32_t eax_10 = CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d /*TurnRight*/, &var_2c); - if (eax_10 == 0) { - add_to_queue(this, var_c, 0x41000003 /*Ready*/, eax_10); - if (params.byte1 & 0x40) - InterpretedMotionState::RemoveMotion(&this->interpreted_state, 0x6500000d); - } -} -``` - -### Per-axis dispatch order (CANONICAL) - -When a UM with all three axes populated arrives, retail does: - -1. `DoInterpretedMotion(current_style, default_params)` — applies - stance (Combat/Peace/Magic/etc.). -2. **If airborne**: `DoInterpretedMotion(Falling)` — forward axis is - skipped, body animates as Falling. -3. **Else if longjump**: `DoInterpretedMotion(Ready)` then - `StopInterpretedMotion(SideStepRight)` — forward pinned, sidestep - killed. -4. **Else (normal grounded path)**: - a. `DoInterpretedMotion(forward_command, params{speed=fwd_speed})` - b. If `sidestep_command == 0`: `StopInterpretedMotion(SideStepRight)` - Else: `DoInterpretedMotion(sidestep_command, params{speed=side_speed})` -5. If `turn_command != 0`: `DoInterpretedMotion(turn_command, params{speed=turn_speed})` and **RETURN**. -6. If `turn_command == 0`: `CPhysicsObj::StopInterpretedMotion(TurnRight, default_params)` directly on physics_obj (skipping the CMotionInterp wrapper), plus `add_to_queue(Ready)`. - -**Note the asymmetry:** the turn-stop bypasses CMotionInterp's -StopInterpretedMotion and goes straight to CPhysicsObj. This is -because the wrapper would re-enter the contact_allows_move check (turns -are always allowed per `contact_allows_move`'s special case), and the -direct call ensures the StopInterpretedMotion fires regardless of state. - ---- - -## 7. CMotionInterp::apply_current_movement (the dispatcher chooser) - -**Function:** `CMotionInterp::apply_current_movement` -**Address:** `0x00528870` -**Lines:** 305838–305857. - -```c -00528870 void apply_current_movement(this, int32_t arg2, int32_t arg3) -{ - if (physics_obj == 0 || initted == 0) return; - - int32_t isPlayer = (weenie_obj != 0) ? weenie_obj->vtable->IsThePlayer() : 0; - - // Local player + autonomous → use RAW path (so HoldKey gets re-applied via adjust_motion) - if ((weenie_obj == 0 || isPlayer != 0) - && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0) - { - apply_raw_movement(this, arg2, arg3); - return; - } - - // Everyone else → INTERPRETED path - apply_interpreted_movement(this, arg2, arg3); -} -``` - -**This is the bifurcation point**: local autonomous player runs -`apply_raw_movement`; remote observer runs `apply_interpreted_movement`. - -Acdream's port (`MotionInterpreter.apply_current_movement`, l.653) is -**a heavily-simplified single-shot** that does NOT branch and does NOT -re-fire per-axis — it just calls `set_local_velocity(get_state_velocity())` -once, gated on `OnWalkable`. **This is the structural divergence.** - ---- - -## 8. CMotionInterp::apply_raw_movement (LOCAL player path) - -**Function:** `CMotionInterp::apply_raw_movement` -**Address:** `0x005287e0` -**Lines:** 305817–305834. - -```c -005287e0 void apply_raw_movement(this, arg2, arg3) -{ - if (physics_obj == 0) return; - - // Bulk-copy 7 fields from RAW → INTERPRETED - this->interpreted_state.current_style = this->raw_state.current_style; - this->interpreted_state.forward_command = this->raw_state.forward_command; - this->interpreted_state.forward_speed = this->raw_state.forward_speed; - this->interpreted_state.sidestep_command = this->raw_state.sidestep_command; - this->interpreted_state.sidestep_speed = this->raw_state.sidestep_speed; - this->interpreted_state.turn_command = this->raw_state.turn_command; - this->interpreted_state.turn_speed = this->raw_state.turn_speed; - - // Re-run adjust_motion ONCE PER AXIS (so HoldKey.Run promotes Walk→Run, etc.) - adjust_motion(this, &interpreted_state.forward_command, - &interpreted_state.forward_speed, - raw_state.forward_holdkey); - adjust_motion(this, &interpreted_state.sidestep_command, - &interpreted_state.sidestep_speed, - raw_state.sidestep_holdkey); - adjust_motion(this, &interpreted_state.turn_command, - &interpreted_state.turn_speed, - raw_state.turn_holdkey); - - // Then dispatch each axis through DoInterpretedMotion (per-axis re-fire) - apply_interpreted_movement(this, arg2, arg3); -} -``` - -**Crucial:** the local player path **also** ends in -`apply_interpreted_movement`. The only difference is that it first -copies RAW → INTERPRETED and runs `adjust_motion` per axis. The -per-axis dispatcher is the same. - -This means there is **exactly ONE per-axis dispatch path** in retail: -`apply_interpreted_movement`. Both local autonomous and remote -observer go through it. - ---- - -## 9. CMotionInterp::move_to_interpreted_state (the UM entry) - -Already covered in `02-um-handling.md` §7. Quick recap: - -```c -005289c0 int32_t move_to_interpreted_state(this, InterpretedMotionState const* arg2) -{ - if (physics_obj == 0) return 0; - - this->raw_state.current_style = arg2->current_style; // raw mirrors style - CPhysicsObj::interrupt_current_movement(physics_obj); - - uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); - - InterpretedMotionState::copy_movement_from(&this->interpreted_state, arg2); // BULK COPY - apply_current_movement(this, 1 /*cancelMoveTo*/, !!allowJump); // PER-AXIS REFIRE - - // Then iterate actions[] with stamp-wrap protection - for each action in arg2->actions where stamp_is_newer: - DoInterpretedMotion(this, action.motion, params{action_stamp, autonomous}); - - return 1; -} -``` - -**copy_movement_from** is unconditional — overwrites all 7 fields: - -```c -InterpretedMotionState::copy_movement_from (lines 293301-293311): - this->current_style = src->current_style; - this->forward_command = src->forward_command; - this->forward_speed = src->forward_speed; - this->sidestep_command = src->sidestep_command; - this->sidestep_speed = src->sidestep_speed; - this->turn_command = src->turn_command; - this->turn_speed = src->turn_speed; -``` - -No diffing. No filter. **If the wire said it, it's in InterpretedState now.** - ---- - -## 10. CMotionInterp::StopInterpretedMotion - -**Function:** `CMotionInterp::StopInterpretedMotion` -**Address:** `0x00528470` -**Lines:** 305635–305670. - -```c -00528470 uint32_t StopInterpretedMotion(this, motion, params) -{ - if (physics_obj == 0) return 8; - - uint32_t result; - - bool airborne_skip = (contact_allows_move(this, motion) == 0) - || (standing_longjump != 0 - && (motion == 0x45000005 || motion == 0x44000007 || motion == 0x6500000f)); - - if (airborne_skip) { - if (params.byte1 & 0x40 /*ModifyInterpretedState*/) - InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); - result = 0; - } else { - result = CPhysicsObj::StopInterpretedMotion(this->physics_obj, motion, params); - if (result == 0) { - add_to_queue(this, params->context_id, 0x41000003 /*Ready*/, result); - if (params.byte1 & 0x40) - InterpretedMotionState::RemoveMotion(&this->interpreted_state, motion); - } - } - - if (physics_obj != 0 && physics_obj->cell == 0) - CPhysicsObj::RemoveLinkAnimations(physics_obj); - - return result; -} -``` - ---- - -## 11. CMotionInterp::StopMotion - -**Function:** `CMotionInterp::StopMotion` -**Address:** `0x00528530` -**Lines:** 305674–305708. - -```c -00528530 uint32_t StopMotion(this, motion, params) -{ - if (physics_obj == 0) return 8; - - if (params.byte1 & 0x80) CPhysicsObj::interrupt_current_movement(physics_obj); - - // Capture all params fields locally (because adjust_motion will mutate motion+speed) - float speed = params.speed; - HoldKey hk = params.hold_key_to_apply; - - arg2 = motion; - int32_t var_2c = 0x7c83f8; // some default flags - adjust_motion(this, &arg2, &speed, hk); // sign-flip + run-promote - - uint32_t result = StopInterpretedMotion(this, arg2, &var_2c); - - if (result == 0 && (params.byte1 & 0x20 /*ModifyRawState*/) != 0) - RawMotionState::RemoveMotion(&this->raw_state, motion); // PRE-adjust motion - - return result; -} -``` - -Same pattern as DoMotion: adjust_motion the motion+speed, dispatch the -adjusted form to StopInterpretedMotion, then optionally update RawState -with the PRE-adjust form. - ---- - -## 12. CMotionInterp::StopCompletely - -**Function:** `CMotionInterp::StopCompletely` -**Address:** `0x00527e40` -**Lines:** 305208–305234. - -```c -00527e40 uint32_t StopCompletely(this) -{ - if (physics_obj == 0) return 8; - - CPhysicsObj::interrupt_current_movement(physics_obj); - uint32_t allowJump = motion_allows_jump(this, this->interpreted_state.forward_command); - - // Reset RAW (5 fields) - this->raw_state.forward_command = 0x41000003 /*Ready*/; - this->raw_state.forward_speed = 1.0f; - this->raw_state.sidestep_command = 0; - this->raw_state.turn_command = 0; - - // Reset INTERPRETED (5 fields — note: side_speed and turn_speed NOT reset here!) - this->interpreted_state.forward_command = 0x41000003; - this->interpreted_state.forward_speed = 1.0f; - this->interpreted_state.sidestep_command = 0; - this->interpreted_state.turn_command = 0; - - CPhysicsObj::StopCompletely_Internal(this->physics_obj); - add_to_queue(this, 0, 0x41000003 /*Ready*/, allowJump); - - if (physics_obj != 0 && physics_obj->cell == 0) - CPhysicsObj::RemoveLinkAnimations(physics_obj); - - return 0; -} -``` - -**Note:** `sidestep_speed` and `turn_speed` are NOT reset to 1.0f -(neither in raw nor interpreted). Only the commands and forward_speed -are touched. Acdream's port (l.510) goes further and resets all six -speeds — slightly more aggressive than retail but harmless because -the speeds are only consumed when the matching command is non-zero. - ---- - -## 13. CMotionInterp::get_max_speed and get_adjusted_max_speed - -**Function:** `CMotionInterp::get_max_speed` -**Address:** `0x00527cb0` -**Lines:** 305127–305141. - -```c -00527cb0 void get_max_speed(this) -{ - CMotionInterp* this_1 = this; // probably an out-param - CWeenieObject* weenie = this->weenie_obj; - this_1 = nullptr; - - if (weenie == 0) return; - if (weenie->vtable->InqRunRate(&this_1) != 0) return; - this->my_run_rate; // (compiler artifact — load only) -} -``` - -The decompile is hard to read because of x87 return-value handling, but -semantically: **return InqRunRate(weenie) if available, else my_run_rate**. -Used by `set_target_movement` (`0x00509ed5` references in 352395, 353112). - -**Function:** `CMotionInterp::get_adjusted_max_speed` -**Address:** `0x00527d00` -**Lines:** 305145–305156. - -```c -00527d00 void get_adjusted_max_speed(this) -{ - CWeenieObject* weenie = this->weenie_obj; - if (weenie != 0 && weenie->vtable->InqRunRate(&this_1) == 0) - this->my_run_rate; // load - if (this->interpreted_state.forward_command == 0x44000007 /*RunForward*/) - ((long double)this->interpreted_state.forward_speed) / ((long double)this->current_speed_factor); -} -``` - -Adjusts the cap by the **current_speed_factor** (an internal multiplier -e.g. for encumbrance / spell effects) when the body is in run state. - -Both functions return a float used as the speed cap inside -`get_state_velocity` (305160) — `len > 4.0L * rate` clamp scale. - ---- - -## 14. InterpretedMotionState::UnPack — flag bits in detail - -(Documented in `02-um-handling.md` §5; restating the bit table here -since this doc is the dispatch reference.) - -| Bit | Field | When CLEAR | -|---|---|---| -| 0x01 | `current_style` | NonCombat (0x8000003d) | -| 0x02 | `forward_command` | Ready (0x41000003) | -| 0x04 | `forward_speed` | 1.0f | -| 0x08 | `sidestep_command` | 0 | -| 0x10 | `sidestep_speed` | 1.0f | -| 0x20 | `turn_command` | 0 | -| 0x40 | `turn_speed` | 1.0f | -| 0x80–0x800 | action count (5 bits) | 0 | - -**Default-on-absence is the per-axis SEMANTIC that drives the stop -signal.** When ACE relays a stop, it omits the forward_command field -(bit 0x02 clear), and UnPack sets `forward_command = Ready`. The -copy_movement_from then writes Ready into InterpretedState. The next -`apply_interpreted_movement` calls `DoInterpretedMotion(Ready, -speed=1.0)`. `get_state_velocity` returns (0,0,0) because Ready is -neither WalkForward nor RunForward. - ---- - -## 15. The complete dispatch sequence for an inbound UM (annotated) - -For a UM with all three axes populated (forward + sidestep + turn) on -a remote observer, **the full call chain** is: - -``` -0xF74C wire packet - CM_Physics::DispatchSB_* (357214) - CPhysics::SetObjectMovement (271370) - CPhysicsObj::unpack_movement (280179) - MovementManager::unpack_movement (300563) - // read 2 bytes movement_type=0 - // read 2 bytes initial_style - if (style != current) DoMotion(style) (306159) <- one DoMotion for STANCE - InterpretedMotionState::UnPack (294360) - // read flags uint32 + each conditional field - MovementManager::move_to_interpreted_state(300259) - CMotionInterp::move_to_interpreted_state(305936) - raw_state.current_style = src.current_style - interrupt_current_movement - motion_allows_jump(prev forward_command) - InterpretedMotionState::copy_movement_from // BULK COPY 7 FIELDS - apply_current_movement(cancelMoveTo=1, allowJump) (305838) - if (local autonomous): - apply_raw_movement (305817) - bulk-copy raw → interpreted - adjust_motion × 3 (forward, sidestep, turn) - apply_interpreted_movement (305713) - else: // remote observer - apply_interpreted_movement (305713) - cache MyRunRate if RunForward - DoInterpretedMotion(current_style) // STYLE - CPhysicsObj::DoInterpretedMotion (varies) - if !contact_allows_move: - DoInterpretedMotion(Falling) // FORWARD branch (airborne) - elif standing_longjump: - DoInterpretedMotion(Ready) - StopInterpretedMotion(SideStepRight) - else: - DoInterpretedMotion(forward_command) // FORWARD axis - CPhysicsObj::DoInterpretedMotion - set_local_velocity(get_state_velocity) - // VELOCITY PUSH FOR FORWARD - if (sidestep_command == 0): - StopInterpretedMotion(SideStepRight) - else: - DoInterpretedMotion(sidestep_command) // SIDESTEP axis - CPhysicsObj::DoInterpretedMotion - set_local_velocity(get_state_velocity) - // VELOCITY PUSH FOR SIDESTEP (additive over forward) - if (turn_command != 0): - DoInterpretedMotion(turn_command) // TURN axis - CPhysicsObj::DoInterpretedMotion - (drives angular velocity / animation omega) - else: - CPhysicsObj::StopInterpretedMotion(TurnRight) - add_to_queue(Ready) - // Then for each action in src.actions: - for each action with newer stamp: - DoInterpretedMotion(action.motion, params{action_stamp, autonomous}) - // OVERLAY axis (Twitch / ChatEmote / etc — not a velocity push) -``` - -**Five DoInterpretedMotion calls per UM in the typical case (style + -forward + sidestep + turn + maybe one action).** Each call into -`CPhysicsObj::DoInterpretedMotion` pushes velocity via -`set_local_velocity(get_state_velocity())`. - -The fact that the velocity is pushed **MULTIPLE TIMES PER UM** (once -after each axis) is what makes the body's local velocity in retail -respond instantly to a multi-axis state change. Acdream's -`apply_current_movement` only pushes velocity once. - ---- - -## 16. Overlay (Action / ChatEmote / Modifier) dispatch - -Action commands (bit 0x10000000) and ChatEmote commands (bit -0x02000000) are overlay motions. They flow through: - -- **Inbound UM**: the `actions[]` list inside `InterpretedMotionState` - (5-bit action count starting at flag 0x80). Each action is - dispatched in `move_to_interpreted_state`'s for-loop via - `DoInterpretedMotion(action.motion, params{action_stamp, autonomous})`. -- **Locally generated**: `DoMotion(motion, params)` with the action - bit set. `DoMotion`'s combat-style guards reject ChatEmote in - combat (`return 0x42`). Actions over the 6-action quota return - 0x45. - -Within `DoInterpretedMotion`, an Action command **does** call -`CPhysicsObj::DoInterpretedMotion` (which routes the command to the -animation sequencer's overlay channel) but `get_state_velocity` does -NOT consume Action commands — they have no velocity contribution. -This is what makes "swing weapon while running" work: the forward -axis still produces RunForward velocity (because `forward_command` -in InterpretedState is still RunForward), and the action just plays -on the overlay channel. - -**Acdream's `AnimationCommandRouter.RouteFullCommand`** is the analog -of the overlay-channel routing inside `CPhysicsObj::DoInterpretedMotion`. -Note that acdream's GameWindow (l.2887–2892) calls RouteFullCommand -**only** when `forwardIsOverlay` and `!remoteIsAirborne`. This ONLY -handles the "first-class" overlay flag in the wire (the special-case -where the wire sets `forward_command` to an Action command directly, -which retail handles via `move_to_interpreted_state`'s bulk copy -followed by `apply_interpreted_movement`'s forward-axis dispatch). - ---- - -## 17. Cross-check: acdream `MotionInterpreter.cs` divergences - -| Retail | Acdream (`MotionInterpreter.cs`) | Verdict | -|---|---|---| -| `apply_interpreted_movement` (305713) — fires DoInterpretedMotion for STYLE + FORWARD + SIDESTEP + TURN | **MISSING.** `apply_current_movement` (l.653) just calls `set_local_velocity(get_state_velocity())` once, gated on OnWalkable. | **CRITICAL GAP** | -| `apply_raw_movement` (305817) — bulk-copy RAW→INTERPRETED, then adjust_motion × 3, then apply_interpreted_movement | **MISSING.** Acdream's local autonomous player path is improvised in `PlayerMovementController` — does not run adjust_motion per axis. | Bug for local-input WalkBackward + HoldKey.Run interaction | -| `DoMotion` (306159) — adjust_motion → DoInterpretedMotion → optional RawState.ApplyMotion | `DoMotion` (l.381) just sets RawState.ForwardCommand+Speed and forwards to `DoInterpretedMotion(modifyInterpretedState:true)`. **No adjust_motion call.** | Bug for local backward/sidestep input | -| `move_to_interpreted_state` (305936) — bulk-copy InterpretedState (7 fields), apply_current_movement, iterate actions | **PARTIAL.** GameWindow.cs:2847 sets only ForwardCommand+ForwardSpeed (NOT current_style, NOT all 7 fields). Sidestep/Turn handled via separate `DoInterpretedMotion`/`StopInterpretedMotion` calls (l.3060, 3066, 3096, 3108). | Functional but structurally divergent | -| `StopMotion` (305674) — adjust_motion the motion, StopInterpretedMotion(adjusted), optional RawMotionState::RemoveMotion(pre-adjust) | `StopMotion` (l.431) — early-rewrites RawState, then forwards. **No adjust_motion call.** | Functional for sign-aligned cases only | -| `apply_run_to_command` (305062) — speed > 0 gate for promote, multiplies speed by speedMod | `MotionInterpreter` doesn't have this method. Wire-arrival gives the post-adjust form so it's not strictly needed for L.3 receive path; but the local SEND path is missing it. | Required for outbound bug parity | -| `ApplyMotion` (293531) — switch by command class (TurnRight / SideStepRight / 0x4xxxxxxx) | `ApplyMotionToInterpretedState` (l.993) — `switch` over **specific commands** (Walk/Run/WalkBackward/SideStepRight/SideStepLeft/TurnRight/TurnLeft/Ready). Other 0x4xxxxxxx commands (Stand, Falling, Crouch, Sit, Sleep) **fall through silently.** | Bug for non-locomotion forward commands | - ---- - -## 18. Answers to the critical questions - -### Q1: Does retail call DoInterpretedMotion separately for forward, sidestep, and turn axes EACH UM AND EACH TICK? - -**EACH UM: YES.** `apply_interpreted_movement` (305713) makes 3–5 -DoInterpretedMotion calls in sequence: style, forward (or Falling / -Ready+SideStop for longjump), sidestep (or stop), turn (or stop). - -**EACH TICK: NO.** `apply_interpreted_movement` does NOT auto-fire on -every physics tick. It fires: -- On every UM intake (via `move_to_interpreted_state` → `apply_current_movement`). -- On `LeaveGround` / `LandingHandler` / `enter_default_state` / `SetWeenieObject` / `SetPhysicsObject` / `ReportExhaustion`. -- On `set_target_movement` and `cancel_moveto`. - -Per-tick, the body's velocity is preserved by the physics solver -(`PhysicsBody::update`) using the velocity that was last set via -`set_local_velocity` from the most recent `apply_interpreted_movement`. -**The body integrates with the SAME velocity until a new state event -fires another `apply_interpreted_movement`.** - -This is why retail does NOT have a "staircase" issue on slopes: the -last `set_local_velocity` from the last UM stays in effect, and the -collision sweep (`ResolveWithTransition`) handles the slope component -naturally. Acdream's env-var path bypassed this by skipping the -`set_local_velocity` re-push at UM time. - -### Q2: When UM arrives with all 3 axes populated, does retail dispatch all 3? In what order? - -**Yes.** Order is fixed in `apply_interpreted_movement`: - -1. STYLE (`current_style` — stance change) -2. FORWARD (or Falling / Ready+SideStop) -3. SIDESTEP (or explicit Stop) -4. TURN (or explicit Stop) - -Then iterate `actions[]` for overlays in stamp order. - -### Q3: What's the canonical "play this cycle now" decision tree based on InterpretedState? - -This is **NOT in CMotionInterp**. The cycle decision lives inside -`CPhysicsObj::DoInterpretedMotion` → `set_motion_table_data`. Each call -to `DoInterpretedMotion(motion, params)` from -`apply_interpreted_movement` ends in a `set_motion_table_data(motion, -speed)` that updates the corresponding cycle slot in the animation -sequencer. - -**The sequencer maintains one cycle per "axis class"** roughly: - -- Forward locomotion cycle: WalkForward / RunForward / Falling / Ready -- Sidestep cycle: SideStepRight (with sign-flip for left) -- Turn cycle: TurnRight (with sign-flip for left) -- Style: stance-class affects which (style, command) pair the motion - table looks up - -Multiple cycles play SIMULTANEOUSLY on different bone subsets, layered -by the motion table's part definitions. This is why "run forward AND -sidestep" produces a strafe-run animation: both cycles play, the parts -each owns are updated by their own cycle. - -The "priority" acdream's `OnLiveMotionUpdated` uses (l.2905-2939: -forward → sidestep → turn → ready, single SetCycle) is **a -simplification** that picks ONE cycle. Retail plays multiple in -parallel via the motion-table layering. - -### Q4: For overlay (Action / Modifier / ChatEmote) packets, does the dispatch chain differ? - -**Slightly.** Overlays (Action bit 0x10000000, ChatEmote bit 0x02000000) -ride in the `actions[]` list inside InterpretedMotionState. After -`copy_movement_from` and `apply_interpreted_movement`, the action -loop iterates with stamp-wrap protection and fires -`DoInterpretedMotion(action.motion, params{action_stamp, autonomous_bit_via_0x1000})`. - -Inside `DoInterpretedMotion`: -- `contact_allows_move` for Action commands always returns 1 (handled - by `(motion & 0x10000000) == 0` check in the false-branch — Action - airborne returns YouCantJumpWhileInTheAir). -- Action commands have no `get_state_velocity` contribution (the - switch in `get_state_velocity` only matches WalkForward / RunForward - / SideStepRight as side-step gate). - -Net effect: Action overlay does NOT change body velocity (forward axis -keeps producing whatever it was producing), but it DOES drive an -animation overlay channel. - -**HOWEVER**: when a UM's `forward_command` IS an Action (e.g. retail -sometimes encodes "swing this attack and stop running" by setting -forward_command = AttackHigh1 directly, bit 0x02 set, no separate -action entry), the bulk-copy lands AttackHigh1 in -`interpreted_state.forward_command`. `apply_interpreted_movement` -fires `DoInterpretedMotion(AttackHigh1, params{speed=fwd_speed})`, -and `get_state_velocity` returns 0 (no match for WalkForward / -RunForward) — body stops moving forward, attack animation plays -on the Action channel via DoInterpretedMotion's set_motion_table_data. - -This is why GameWindow.cs:2802-2855 (acdream's "lifted bulk-copy" -block) is correctly doing `InterpretedState.ForwardCommand = fullMotion` -unconditionally — to match retail's bulk-copy semantics. - ---- - -## 19. What would the "correct" UM handler do that acdream is missing? - -Given an inbound 0xF74C UM with movement_type=0 (RawCommand), retail's -end-to-end flow is: - -``` -1. STALENESS — check 16-bit instance_ts via SetObjectMovement; - drop if older. -2. STYLE PRE-DISPATCH — if (new_style != current), DoMotion(new_style) - to swap stance. -3. UNPACK — InterpretedMotionState::UnPack reads flags + each - conditional field. -4. BULK COPY — copy_movement_from writes all 7 fields into the body's - InterpretedState. -5. PER-AXIS RE-FIRE — apply_interpreted_movement runs: - a. DoInterpretedMotion(current_style) - b. DoInterpretedMotion(forward_command, params{speed=fwd_speed}) OR Falling-branch - c. DoInterpretedMotion(sidestep_command, params{speed=side_speed}) OR StopInterpretedMotion(SideStepRight) - d. DoInterpretedMotion(turn_command, params{speed=turn_speed}) OR StopInterpretedMotion(TurnRight) + Ready add_to_queue - Each of these fires CPhysicsObj::DoInterpretedMotion, which calls - set_local_velocity(get_state_velocity()) AND drives the animation - sequencer's per-axis cycle slot. -6. ACTIONS LOOP — iterate actions[] with stamp-wrap protection; - DoInterpretedMotion each newer action. -``` - -**Acdream is missing #5 (the per-axis re-fire) at the granularity -retail has.** - -What acdream does on UM (`OnLiveMotionUpdated` + `MotionInterpreter.apply_current_movement`): - -- ✅ Staleness (timestamp check in WorldSession parser). -- ✅ Style change (stance update). -- ✅ Unpack (parser handles flag bits). -- ✅ Bulk-copy ForwardCommand+ForwardSpeed (l.2847, 2855). **PARTIAL** — - doesn't bulk-copy current_style separately (relies on `Stance` - being lifted up by parser). -- ❌ Per-axis re-fire of `apply_interpreted_movement`. **MISSING.** - Instead: - - Sidestep: directly calls `DoInterpretedMotion(sideFull, sideSpd, modifyInterpretedState:true)` (l.3060). - - Turn: directly calls `DoInterpretedMotion(turnFull, turnSpd, modifyInterpretedState:true)` (l.3096). - - These each fire `MotionInterpreter.apply_current_movement` once, which only does ONE `set_local_velocity` call. -- ❌ Single `set_local_velocity` per UM rather than per-axis. **CRITICAL.** - Effect: after-Forward velocity overwrites after-Sidestep velocity - rather than building up. (Mitigated by `get_state_velocity` reading - ALL three axis fields in one call — so the final velocity is correct. - But the per-axis sequencer cycle slots may not all update because - `MotionInterpreter.apply_current_movement` doesn't drive the sequencer.) -- ❌ `apply_interpreted_movement`'s STYLE pre-fire. **MISSING.** Stance - changes don't get a DoInterpretedMotion call — this is why "draw - weapon while running" sometimes shows wrong stance pose. -- ❌ `apply_interpreted_movement`'s longjump branch. **MISSING.** - StandingLongJump-charged forward gets pinned to Ready in retail; in - acdream the forward command is whatever the wire said. -- ❌ `apply_interpreted_movement`'s explicit StopInterpretedMotion for - zero-axis. **PARTIAL.** Acdream's GameWindow.cs:3066-3070 calls - `StopInterpretedMotion(SideStepRight)` AND `StopInterpretedMotion(SideStepLeft)` - when sidestep is 0; same for turn at 3108-3111. Retail only stops - SideStepRight and TurnRight (relies on adjust_motion having - normalized Left → Right + sign). -- ❌ Falling-when-airborne fallback. **WORKAROUND.** acdream handles - this via `remoteIsAirborne` checks scattered through - `OnLiveMotionUpdated`, NOT in `apply_current_movement`. - -### Concrete fix proposal - -Port `apply_interpreted_movement` faithfully into MotionInterpreter.cs -and have `apply_current_movement` (l.653) **delegate** to it for the -remote-observer path. The single `set_local_velocity` call should -move INSIDE `CPhysicsObj.DoInterpretedMotion` (or a new method -`MotionInterpreter.DispatchAxis(motion, speed)`) so each axis call -both updates InterpretedState AND pushes velocity AND drives the -sequencer. - -Then `OnLiveMotionUpdated` becomes much shorter: - -``` -OnLiveMotionUpdated: - remoteMot.Motion.move_to_interpreted_state(wireInterpretedState) - // That single call does bulk-copy + per-axis dispatch + sequencer drive -``` - -This eliminates the divergence between "what retail does on UM" and -"what acdream does on UM" and removes the need for the heavy ad-hoc -logic at GameWindow.cs:2774–3300. - ---- - -## 20. Cross-references - -- Retail decomp source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` -- Companion docs: - - `02-um-handling.md` — UM intake top-level chain - - `03-up-routing.md` — UpdatePosition (0xF748) — separate path - - `04-interp-manager.md` — MovementManager + MoveToManager - - `06-acdream-audit.md` — full acdream audit -- Acdream code: - - `src/AcDream.Core/Physics/MotionInterpreter.cs` - - `src/AcDream.App/Rendering/GameWindow.cs::OnLiveMotionUpdated` (l.2579-3300) - - `src/AcDream.Core/Physics/AnimationCommandRouter.cs` (overlay channel routing) -- Retail named symbols (all addresses 0x005xxxxx in acclient.exe v11.4186): - - `CMotionInterp::PerformMovement` 0x00528e80 (306221) - - `CMotionInterp::DoMotion` 0x00528d20 (306159) - - `CMotionInterp::DoInterpretedMotion` 0x00528360 (305575) - - `CMotionInterp::StopMotion` 0x00528530 (305674) - - `CMotionInterp::StopInterpretedMotion` 0x00528470 (305635) - - `CMotionInterp::StopCompletely` 0x00527e40 (305208) - - `CMotionInterp::apply_current_movement` 0x00528870 (305838) - - `CMotionInterp::apply_interpreted_movement` 0x00528600 (305713) - - `CMotionInterp::apply_raw_movement` 0x005287e0 (305817) - - `CMotionInterp::move_to_interpreted_state` 0x005289c0 (305936) - - `CMotionInterp::adjust_motion` 0x00528010 (305343) - - `CMotionInterp::apply_run_to_command` 0x00527be0 (305062) - - `CMotionInterp::get_state_velocity` 0x00527d50 (305160) - - `CMotionInterp::get_max_speed` 0x00527cb0 (305127) - - `CMotionInterp::get_adjusted_max_speed` 0x00527d00 (305145) - - `CMotionInterp::contact_allows_move` 0x00528240 (305471) - - `CMotionInterp::enter_default_state` 0x00528c80 (306124) - - `CMotionInterp::HandleExitWorld` 0x00527f30 (305275) - - `MovementManager::PerformMovement` 0x005240d0 (300194) - - `MovementManager::unpack_movement` 0x00524440 (300563) - - `MovementManager::move_to_interpreted_state` 0x00524170 (300259) - - `InterpretedMotionState::UnPack` 0x0051f400 (294360) - - `InterpretedMotionState::ApplyMotion` 0x0051ea40 (293531) - - `InterpretedMotionState::copy_movement_from` (293301-293311) diff --git a/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md b/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md deleted file mode 100644 index 4552afde..00000000 --- a/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.md +++ /dev/null @@ -1,745 +0,0 @@ -# Hard-Teleport (Branch A) + Sequence-Number Plumbing — Retail Pseudo-C Extract - -**Date:** 2026-05-04 -**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C) -**Cross-reference:** `docs/research/named-retail/acclient.h`, -`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs`, -`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PositionPack.generated.cs` -**Companion:** `03-up-routing.md` (the standard tri-state router) - -This note drills into Branch A (hard-teleport) of `MoveOrTeleport`, the -`newer_event` 16-bit-wrap helper, the `update_times[]` slot map, the -`SmartBox::HandleReceivedPosition` instance-stamp gate, the -`SetPosition`/`SetPositionInternal` flag-bit decoder used by Branch A, -the wire layout of the four PositionPack u16 stamps, and acdream's -current end-to-end gap. - ---- - -## 1. `CPhysicsObj::newer_event` — the 16-bit-wrap stamp comparator - -**File line:** 90712 — `0x00451b10` - -Verbatim retail (decompiler abs-delta noise replaced with the -arithmetic that's actually emitted): - -```c -90712 int32_t __thiscall CPhysicsObj::newer_event( -90712 class CPhysicsObj* this, -90712 enum PhysicsTimeStamp arg2, // slot index 0..8 -90712 uint16_t arg3) // wire-side new stamp -90712 { -90716 esi = this->update_times[arg2]; // stored stamp -90718 edi = arg3; // received stamp -90721 // signed delta: diff = (int32)((uint32)edi - (uint32)esi) -90722 // abs(diff) → eax_4 -90723 eax_4 = abs(diff); -90726 if (eax_4 > 0x7fff) -90727 c = (edi < esi); // wrapped: received is OLDER -90728 else -90729 c = (esi < edi); // not wrapped: received is NEWER if c -90731 if (diff == 0) -90732 return 0; // EQUAL → not newer -90734 this->update_times[arg2] = edi; // STORE new value -90735 return 1; // NEWER → 1 -90712 } -``` - -**Decoder note.** The Binary Ninja output expands the abs(delta) into -`HIGHD/LOWD ^ - -` salad (lines 90721–90723) and the early-return at -90731 reads as `-((eax_4 - eax_4)) == 0`, which the optimizer collapses -into `if (diff == 0) return 0;`. The stripped semantic equivalent -(matches ACE `WorldObject_Networking.cs::is_newer_event`, -`PhysicsObj.cs::CheckIsNewer`): - -```csharp -static bool IsNewer16(ushort prev, ushort received) { - int diff = (ushort)(received - prev); // unsigned 16-bit subtract - if (diff == 0) return false; // equal → not newer - return diff < 0x8000; // <32k forward → newer; ≥32k → older -} -``` - -**Side effect.** This is **NOT pure** — the function writes -`update_times[arg2] = edi` when it returns 1. So the four -`newer_event` calls inside `HandleReceivedPosition` / -`MoveOrTeleport` simultaneously test *and* commit the new stamp. -Any port must preserve "test-and-set" semantics or the next UP will -re-fire as if it were the first. - ---- - -## 2. `update_times[9]` slot map - -**Header:** `acclient.h:6084` (verbatim): - -```c -enum PhysicsTimeStamp -{ - POSITION_TS = 0x0, - MOVEMENT_TS = 0x1, - STATE_TS = 0x2, - VECTOR_TS = 0x3, - TELEPORT_TS = 0x4, - SERVER_CONTROLLED_MOVE_TS = 0x5, - FORCE_POSITION_TS = 0x6, - OBJDESC_TS = 0x7, - INSTANCE_TS = 0x8, - NUM_PHYSICS_TS = 0x9, -}; -``` - -`CPhysicsObj` carries `unsigned __int16 update_times[9]` (`acclient.h:30738`). -The four PositionPack u16s map onto **four** of these slots: - -| Wire field (PositionPack order) | Slot | Slot index | Used in MoveOrTeleport? | -|---|---|---|---| -| `instance_timestamp` | INSTANCE_TS | 8 | gate in UnpackPositionEvent (must EQUAL) | -| `position_timestamp` | POSITION_TS | 0 | gate in HandleReceivedPosition (must be newer) | -| `teleport_timestamp` | TELEPORT_TS | 4 | **Branch A trigger** in MoveOrTeleport | -| `force_position_timestamp` | FORCE_POSITION_TS | 6 | local-player BlipPlayer trigger | - -The remaining slots (MOVEMENT_TS, STATE_TS, VECTOR_TS, -SERVER_CONTROLLED_MOVE_TS, OBJDESC_TS) are stamped by separate -opcodes — UpdateMotion (0xF74C), VectorUpdate (0xF74E), -ObjDesc, etc. — not by 0xF748. - ---- - -## 3. `SmartBox::HandleReceivedPosition` (full) — staleness gates around Branch A - -**File line:** 92896 — `0x00453fd0` - -Verbatim retail (de-noised from line numbers preserved): - -```c -92896 void __thiscall SmartBox::HandleReceivedPosition( -92896 class SmartBox* this, -92896 class CPhysicsObj* arg2, // target object -92896 class Position const* arg3, // received position -92896 uint32_t arg4, // placement_id -92896 int32_t arg5, // has_contact (0=air, !=0 grounded) -92896 class AC1Legacy::Vector3 const* arg6, // velocity -92896 uint16_t arg7, // POSITION_TS (position_timestamp) -92896 uint16_t arg8, // TELEPORT_TS (teleport_timestamp / move-seq) -92896 uint16_t arg9) // FORCE_POSITION_TS -92898 { -92901 objcell_id = arg3->objcell_id; -92902 var_48 = 0x796910; // Position vtable -92904 Frame::operator=(&var_40, &arg3->frame); // local copy of frame -92905 player = this->player; - - // ───────── (1) LOCAL PLAYER force-position blip ───────── -92907 if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9) != 0) { -92910 ebp = player->update_times[TELEPORT_TS]; - // peek-only: is the teleport_ts EQUAL to ours? -92923 if (signed_delta_is_zero(ebp, arg8)) { -92925 CPhysicsObj::get_heading(player); -92927 Frame::set_heading(&var_40, currentHeading); -92928 SmartBox::BlipPlayer(this, &var_48); // server forced our pos -92929 player->update_times[POSITION_TS] = arg7; -92931 cmdinterp->vtable->SendPositionEvent(cmdinterp); -92932 return; - } - } - - // ───────── (2) PEEK ebp = current POSITION_TS, run TEST-AND-SET ───────── -92936 ebp = arg2->update_times[POSITION_TS]; // save old stamp -92938 if (newer_event(arg2, POSITION_TS, arg7) == 0) { - // not newer — STALE position; nothing to do unless the teleport - // stamp is somehow different (logging only). -92941 esi = arg2->update_times[TELEPORT_TS]; -92954 if (signed_delta_nonzero(arg8, esi)) -92955 ++error_count; -92957 return; // ← stale UP: no body change - } - - // ───────── (3) TELEPORT_TS sanity vs received arg8 ───────── -92961 ecx_4 = arg2->update_times[TELEPORT_TS]; -92974 if (signed_delta_nonzero(ecx_4, arg8)) { - // received teleport_ts is OLDER than recorded — rewind position - // stamp & bail (this branch is safety, not a normal path). -92976 arg2->update_times[POSITION_TS] = ebp; -92977 return; - } - - // ───────── (4) Detach from any parent + re-place ───────── -92982 parent = arg2->parent; -92982 if (parent != 0 && parent->id != this->player_id) { -92984 weenie = CObjectMaint::GetWeenieObject(arg2->id); -92986 if (weenie != 0) -92987 weenie->vtable->SetParentedState(weenie, 0); - } -92990 CPhysicsObj::unset_parent(arg2); -92992 if (CPhysicsObj::HasAnims(arg2) == 0) -92993 CPhysicsObj::SetPlacementFrame(arg2, arg4, 1); - - // ───────── (5) REMOTE OBJECT branch (the L.3 target) ───────── -92995 if (arg2 != this->player) { -92997 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0) { - // … ConstrainTo with start/max constraint distances … -93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, …); - } -93010 return; - } - - // ───────── (6) LOCAL PLAYER teleport branch ───────── -93013 if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0) { -93015 SmartBox::TeleportPlayer(this, &var_48); - // ConstrainTo + zero velocity -93024 CPhysicsObj::ConstrainTo(arg2, &var_48, …); -93029 CPhysicsObj::set_velocity(player, &zero, 1); -93030 return; - } - - // ───────── (7) LOCAL PLAYER soft-correct ───────── -93041 CPhysicsObj::ConstrainTo(this->player, &var_48, …); -93044 if (cmdinterp->UsePositionFromServer() != 0 && arg5 != 0) { -93047 autonomyLevel = cmdinterp->GetAutonomyLevel(); -93049 CPhysicsObj::InterpolateTo(arg2, &var_48, autonomyLevel != 0); - } -92896 } -``` - -**Critical: the order of stamp updates.** - -1. `INSTANCE_TS` is gated equality-only in `UnpackPositionEvent` (line - 93081) — **never** stored as "newer", just verified equal. -2. `POSITION_TS` is test-and-set at line 92938 by `newer_event(POSITION_TS, arg7)`. -3. `TELEPORT_TS` is **peeked** at line 92974 (sanity), then test-and-set at - line 93013 (player branch) **or** line 284325 (remote branch via - MoveOrTeleport). -4. `FORCE_POSITION_TS` is test-and-set at line 92907. - -So a single 0xF748 stamps up to **3** slots: POSITION_TS always (if -newer), and either TELEPORT_TS (if teleport advanced) or -FORCE_POSITION_TS (if force advanced) but generally not both. - ---- - -## 4. `SmartBox::UnpackPositionEvent` — INSTANCE_TS staleness gate - -**File line:** 93055 — `0x004542c0` - -Already covered in `03-up-routing.md` Section 2. The salient points -re-stated: - -```c -93055 enum NetBlobProcessedStatus SmartBox::UnpackPositionEvent(...) -93055 { -93059 PositionPack::PositionPack(&var_68); -93060 PositionPack::UnPack(&var_68, arg3, arg4); // ← reads bytes -93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2); -93063 if (eax_1 != 0) { -93065 ecx_4 = eax_1->update_times[INSTANCE_TS=8]; -93081 if (signed_delta_is_zero(received_inst, ecx_4)) { - // ★ EQUAL ⇒ proceed; UN-EQUAL is the loggedout / queue path -93083 if (ecx_4 != received_inst) -93084 return NETBLOB_LOGGED_OUT; // (= 2) -93092 SmartBox::HandleReceivedPosition(this, eax_1, -93092 &recvPos, placement_id, has_contact, &velocity, -93092 position_ts, teleport_ts, force_position_ts); -93093 return NETBLOB_PROCESSED_OK; // (= 1) - } -93095 } -93097 return NETBLOB_QUEUED; // (= 4) -93055 } -``` - -The instance gate is "equality-or-drop." Reasoning: instance stamp -counts character logins. If the server has bumped it (player relogged) -the client's still-cached object is the OLD instance — defer or -drop. UnPack consumed the bytes, so the buffer pointer advanced -either way; only the side effect on the body is gated. - -The 16-bit-wrap math is identical to `newer_event`'s, but **without** -the test-and-set side effect — INSTANCE_TS is bumped elsewhere -(CreateObject path). - ---- - -## 5. `CPhysicsObj::MoveOrTeleport` — Branch A dissection - -**File line:** 284304 — `0x00516330` (full extract in `03-up-routing.md`) - -Branch A specifically: - -```c -284321 if (signed_delta_is_zero(this_1->update_times[TELEPORT_TS], arg3)) // (a) TELEPORT_TS sanity -284321 { -284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3); // (b) test-and-set -284325 // ↑ -284325 // writes update_times[4]=arg3 if newer -284327 if (eax_8 != 0 || this_1->cell == 0) // (c) Branch A predicate -284327 { - // ───── BRANCH A: HARD TELEPORT ───── -284329 int32_t var_70_3 = 1; // unused local -284330 CPhysicsObj::teleport_hook(this_1, edx_2); // (d) teleport_hook -284331 SetPositionStruct sps; -284332 SetPositionStruct::SetPositionStruct(&sps); -284333 SetPositionStruct::SetPosition(&sps, arg2); // copy received Position -284334 SetPositionStruct::SetFlags(&sps, 0x1012); // (e) Slide+Placement+SendPositionEvent -284335 CPhysicsObj::SetPosition(this_1, &sps); // run CTransition + place -284336 SetPositionStruct::~SetPositionStruct(&sps); -284337 return 1; - } - /* …Branch B / Branch C (see 03-up-routing.md) … */ - } -``` - -**Important nuance — sanity vs trigger.** The `signed_delta_is_zero` -at line 284321 is a *sanity* gate — it actually means -"abs(delta) == 0", i.e. "the recorded TELEPORT_TS minus the received -arg3 is zero, OR the abs-equality compare path was taken". In retail -this path is taken when the wire stamp is **equal to or newer than** -recorded (the OLDER case is filtered via the `signed_delta_nonzero` -at 92974 in HandleReceivedPosition before MoveOrTeleport is -called). Inside MoveOrTeleport, line 284325's `newer_event` is the -**actual** "is-newer" decision, with side-effect. - -So in the abstract: - -| Wire `arg3` vs recorded `update_times[TELEPORT_TS]` | Path | -|---|---| -| OLDER (wrap-aware) | Already filtered at 92974 — never reaches MoveOrTeleport | -| EQUAL | `newer_event` returns 0 ⇒ Branch A predicate evaluates `0 \|\| (cell==0)` — Branch A ONLY if cell unset; otherwise fall through to Branch B/C | -| NEWER | `newer_event` returns 1, stores arg3 ⇒ Branch A predicate true ⇒ Branch A fires | - -And **`cell == 0`** is the bootstrap case — a `CPhysicsObj` that has -been allocated but not yet placed (e.g., we got an UP for an entity -mid-teleport, before its initial cell entry). Forces a hard placement -even with equal stamp, because there's nowhere else to put it. - -### `teleport_hook` (line 283115 — `0x00514ed0`) - -```c -283115 void CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2) -283115 { -283118 if (this->movement_manager != 0) -283124 MovementManager::CancelMoveTo(this->movement_manager, ctx=0x3c); -283129 if (this->position_manager != 0) -283130 PositionManager::UnStick(this->position_manager); -283134 if (this->position_manager != 0) -283135 PositionManager::StopInterpolating(this->position_manager); // ← drops queue -283139 if (this->position_manager != 0) -283140 PositionManager::UnConstrain(this->position_manager); -283144 if (this->target_manager != 0) { -283146 TargetManager::ClearTarget(this->target_manager); -283147 TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus); - } -283150 CPhysicsObj::report_collision_end(this, 1); -283115 } -``` - -**What teleport_hook does (for the port):** -1. Cancels any in-progress MoveTo route (the AI's "go to door" command). -2. UnStick: clear the "stuck against wall" recovery state. -3. **StopInterpolating: clear the position queue** — required before - the hard-snap so no stale waypoint pulls the body away. -4. UnConstrain: clear distance-from-anchor constraints. -5. Clear target lock; notify nearby observers we teleported. -6. Report a "collision end" so adjacent collision listeners stop - tracking us. - -Steps 3 and 6 are the L.3-relevant ones for acdream. Steps 1, 2, 4, 5 -are tangential to position routing. - ---- - -## 6. `SetPosition` / `SetPositionInternal` flag bit decoder (0x1012) - -**Outer** `SetPosition` (line 284137 — `0x005160c0`) builds a -CTransition over the body's spheres and forwards to the -flag-decoder `SetPositionInternal`. - -The **flag-decoder** `SetPositionInternal` at line 284117 — `0x00516040`: - -```c -284117 enum SetPositionError SetPositionInternal(class SetPositionStruct const* arg2, -284117 class CTransition* arg3) -284117 { -284120 if ((arg2->flags & 0x0200) != 0) // bit 9 = SCATTER -284121 return SetScatterPositionInternal(this, arg2, arg3); - -284123 objcell_id = arg2->pos.objcell_id; -284124 var_48 = 0x796910; -284126 Frame::operator=(&var_40, &arg2->pos.frame); -284127 result = SetPositionInternal(this, &recvPos, arg2, arg3); // run normal path - -284129 if (result != OK_SPE && (arg2->flags & 0x0100) != 0) // bit 8 = ALLOW_SCATTER_FALLBACK -284130 return SetScatterPositionInternal(this, arg2, arg3); - -284132 return result; -284117 } -``` - -The middle dispatcher `SetPositionInternal(this, Position*, sps, trans)` -(line 283892) runs `AdjustPosition` then dispatches based on -`sps->flags & 0x20` and other bits, and ultimately calls the inner -`SetPositionInternal(this, CTransition*)` (line 283399) to commit -the body state. - -Inside `CheckPositionInternal` (line 280070 — `0x00511e90`) and -the middle dispatcher we see the bit checks: - -| Bit | Hex | Used at | Semantic (cross-checked with ACE `SetPositionFlags.cs`) | -|----:|----:|---|---| -| 0 | 0x0001 | 284129 | **ALLOW_SCATTER_FALLBACK** — if the precise placement fails, retry as a scatter (±xrad/yrad) placement. | -| 1 | 0x0002 | 284120 | **SCATTER** — go straight to scatter placement (used by `SetScatterPositionInternal`). | -| 4 | 0x0010 | 280075, 280080 | **PLACEMENT_ALLOW_SLIDING** — the sphere will slide along walls during the placement search instead of being rejected on first contact. **Set in MoveOrTeleport's 0x1012.** | -| 5 | 0x0020 | 283929 | **DO_NOT_LOAD_CELLS** — the cell array is left as "do_not_load_cells = 1"; used when streaming hasn't committed the cell yet. | -| 8 | 0x0100 | 284129 | (see bit 0 — same word, different reading) | -| 9 | 0x0200 | 284120 | (see bit 1 — same word, different reading) | -| 11 | 0x0800 | — | **IS_PORTAL_TRAVEL** (per ACE) — not seen on the MoveOrTeleport paths. | -| 12 | 0x1000 | always present in MoveOrTeleport flags | **SEND_POSITION_EVENT** — after placement, fire the position-event broadcast back through cmdinterp. **Set in MoveOrTeleport's 0x1012 and SetPositionSimple's 0x1002/0x1012.** | - -Note that ACE's `SetPositionFlags.cs` and the older WorldBuilder -references use **different** symbolic names; the bit assignments -above match what's actually decoded in the retail pseudo-C against -`SetPositionStruct::flags` field (a `uint32_t`). - -**Decoded `0x1012`** = `0x1000 | 0x0010 | 0x0002`. Hmm — **bit 1 -(0x0002)** is set too. Re-checking: the OR is -**0x1000 + 0x0010 + 0x0002 = 0x1012**. This means MoveOrTeleport's -Branch A also sets the **SCATTER** bit. That's the inverse of what -prior research note (`03-up-routing.md` Section 5) recorded — let's -re-derive from the source. - -Re-parse: -- `0x1012` = binary `0001 0000 0001 0010`. Set bits: 1, 4, 12. -- Bit 1 = SCATTER (line 284120 takes the scatter path on `& 0x0200`). -- Wait — `0x0200` is bit **9**, not bit 1. Let me reread: - -```c -284120 if ((*(uint8_t*)((char*)((int16_t)arg2->flags))[1] & 2) != 0) -``` - -The decompiler is reading byte 1 (bits 8-15) of `flags` and ANDing -that byte with `2` — meaning it's testing **bit 9** of `flags`, not -bit 1. So `0x0200` is the SCATTER bit. With `0x1012` = bits 1, 4, 12, -the bit-1 (`0x0002`) is **NOT** the scatter bit — it's something -else entirely. - -Updated table: - -| Decimal bit | Hex | Semantic | -|--:|--:|---| -| 0 | 0x0001 | (unknown — possibly SLIDE; see ACE) | -| 1 | 0x0002 | **PLACEMENT** — the position is a fresh placement (vs. continuation of motion). | -| 4 | 0x0010 | **PLACEMENT_ALLOW_SLIDING** — sphere slides during placement search (line 280075). | -| 8 | 0x0100 | **ALLOW_SCATTER_FALLBACK** — retry scatter on placement failure (line 284129). | -| 9 | 0x0200 | **SCATTER** — initial scatter placement (line 284120). | -| 12 | 0x1000 | **SEND_POSITION_EVENT** — broadcast position after place. | - -So `0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT`. -This is consistent with retail's `SetPositionSimple(slide=1)` which -emits `0x1012` and the prior `03-up-routing.md` characterization -(Slide + Placement + SendPositionEvent). - -`0x1002` (used by `SetPositionSimple(slide=0)`) = `SEND_POSITION_EVENT | PLACEMENT` -— **without** the PLACEMENT_ALLOW_SLIDING bit, so the sphere must -fit at the exact spot or fail. - -`0x0011` (used by `enter_world` line 284208) = `PLACEMENT | PLACEMENT_ALLOW_SLIDING` -— enter without broadcasting an event. - -`0x0001` (used by `enter_world` line 284205 default) = `(unknown)` -or possibly `SLIDE` — only one bit set; behaves as the bare-minimum -placement. - -**Cross-reference recommendation:** ACE's -`Source/ACE.Server/Physics/SetPositionFlags.cs` should be the ground -truth for symbolic names. The retail PDB decompile shows the bit -positions but not the names — Turbine compiled the enum out. - ---- - -## 7. Wire format — PositionPack on 0xF748 - -**ACE `Source/ACE.Server/Network/Structure/PositionPack.cs:90-115`:** - -```csharp -public static void Write(this BinaryWriter writer, PositionPack position) -{ - writer.Write((uint)position.Flags); // u32 PositionFlags - writer.Write(position.Origin); // u32 cellId + Vector3 pos - if ((flags & OrientationHasNoW) == 0) writer.Write(Rotation.W); - if ((flags & OrientationHasNoX) == 0) writer.Write(Rotation.X); - if ((flags & OrientationHasNoY) == 0) writer.Write(Rotation.Y); - if ((flags & OrientationHasNoZ) == 0) writer.Write(Rotation.Z); - if ((flags & HasVelocity) != 0) writer.Write(position.Velocity); // 3xf32 - if ((flags & HasPlacementID) != 0) writer.Write((uint)position.PlacementID); - - writer.Write(position.InstanceSequence); // u16 - writer.Write(position.PositionSequence); // u16 - writer.Write(position.TeleportSequence); // u16 - writer.Write(position.ForcePositionSequence); // u16 -} -``` - -**Chorizite generated parser (`PositionPack.generated.cs:65-91`) -matches.** The wire order at the end is: - -``` -... ObjectInstanceSequence u16 -... ObjectPositionSequence u16 -... ObjectTeleportSequence u16 -... ObjectForcePositionSequence u16 -``` - -**ACE's TeleportSequence advance logic** (`PositionPack.cs:46-54`): - -```csharp -InstanceSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectInstance); -PositionSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectPosition); // ← always advanced -if (adminMove) - TeleportSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectTeleport); -else - TeleportSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectTeleport); -ForcePositionSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectForcePosition); -``` - -**Critical: TeleportSequence advances ONLY when `adminMove=true`.** - -`adminMove` is a parameter of `WorldObject.SendUpdatePosition(bool adminMove = false)` -(`WorldObject_Networking.cs:430`). Searching the ACE codebase for -`SendUpdatePosition(true)` returns 0 hits (verified via grep). The -parameter is documented as `"only used if admin is teleporting a -non-player object"` (line 429 comment) — i.e., GM `@teleto` -a creature. - -**For player teleport (portal travel, recall, lifestone, death respawn):** -ACE sends the **dedicated** `GameMessagePlayerTeleport` (opcode -**0xF751**) which carries only the next ObjectTeleport stamp -(`GameMessagePlayerTeleport.cs:10`): - -```csharp -Writer.Write(player.Sequences.GetNextSequence(Sequence.SequenceType.ObjectTeleport)); -``` - -…then immediately follows with `SendUpdatePosition()` carrying the -*new* (already-advanced) TeleportSequence -(`Player_Location.cs:686-694`). - -**Net effect for remote-observer ports (acdream's case): the -TeleportSequence in 0xF748 advances when:** - -1. The remote was a player who teleported (PlayerTeleport advanced - their own seq, then their next UP carries the new value). -2. A GM `@teleto`-ed a creature (admin code path sets - `adminMove=true`). - -**The TeleportSequence does NOT advance for:** -- Normal walking / running movement -- Normal AI patrol -- Mob-hunt path updates -- Position-only correction broadcasts -- Force-position blips (those use ForcePositionSequence) - -So Branch A in retail fires on remotes specifically when a player -just portal-jumped, GM-teleported, lifestone-recalled, or -respawned. **Test cases for the L.3 port:** - -1. Cast a portal recall spell while a remote observer watches you. -2. Step into a portal while another character is nearby. -3. Die — respawn at lifestone with a remote watching. -4. `@teleto` your character via GM command while another's nearby. - ---- - -## 8. Cross-check: acdream's current sequence-number plumbing - -### 8a. Inbound parser - -`src/AcDream.Core.Net/Messages/UpdatePosition.cs:68-70, 152-159`: - -```csharp -public readonly record struct Parsed( - uint Guid, - CreateObject.ServerPosition Position, - System.Numerics.Vector3? Velocity, - uint? PlacementId, - bool IsGrounded, - ushort InstanceSequence = 0, - ushort TeleportSequence = 0, - ushort ForcePositionSequence = 0); -``` - -The four u16s are read at parse (file lines 152-159). PositionSequence -is consumed for buffer alignment but **not stored** (comment: "not -tracked by movement"). - -### 8b. WorldSession dispatch - -`src/AcDream.Core.Net/WorldSession.cs:701-717`: - -```csharp -var posUpdate = UpdatePosition.TryParse(body); -if (posUpdate is not null) -{ - // Update sequence counters from the player's own position updates. - if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id) - { - _instanceSequence = posUpdate.Value.InstanceSequence; - _teleportSequence = posUpdate.Value.TeleportSequence; // OUR seq; for outbound use - _forcePositionSequence = posUpdate.Value.ForcePositionSequence; - } - - PositionUpdated?.Invoke(new EntityPositionUpdate( - posUpdate.Value.Guid, - posUpdate.Value.Position, - posUpdate.Value.Velocity, - posUpdate.Value.IsGrounded)); // ← stamps DROPPED HERE -} -``` - -**The four sequence numbers DO arrive.** For the local player they're -copied into `_teleportSequence`/`_forcePositionSequence` for OUTBOUND -use only (used at GameWindow.cs:5294, 5312, 5329 to stamp our -0xF61C MoveToState packets). For remote players / NPCs the stamps -**never leave WorldSession** — the `EntityPositionUpdate` record -defined at line 110-114 has only `Guid, Position, Velocity, IsGrounded`. - -### 8c. Downstream effect - -`src/AcDream.App/Rendering/GameWindow.cs::OnLivePositionUpdated` (line 3312) -has no awareness that the inbound UP carried a teleport stamp at all. -The L.3 environment-variable path (lines 3508-3625) implements -Branches B (in-bubble Interpolate), C (out-of-bubble snap), -"first UP seed", and air no-op — but **Branch A is never separately -taken**. A player teleport that hits a remote observer just falls -through whichever of B/C/seed/air path the position happens to hit: - -- if airborne (e.g. portal exit at high altitude): air no-op ⇒ - body keeps falling locally, NEVER moves to the new portal-exit - position until the remote lands. -- if grounded and within 96m: enqueue the new position, then chase - it at walking speed across however far the teleport went — - visible "teleport-creep" of up to many meters. -- if grounded and beyond 96m: snap (this is correct by accident, - because the teleport sent us > 96m). - -### 8d. What's needed for the port - -Plumb the four u16 stamps from `WorldSession.EntityPositionUpdate` -into `OnLivePositionUpdated`'s `RemoteMotionState`, then on every UP: - -1. INSTANCE_TS: equality check (already implicit via the GUID - matching the live entity). -2. POSITION_TS: drop the UP if not newer-by-wrap. (Currently - acdream applies every UP, even out-of-order ones.) -3. TELEPORT_TS: test-and-set with the wrap-aware comparator. If - newer, fire Branch A: - - Equivalent of `teleport_hook`: clear `rmState.Interp` queue, - call `report_collision_end` on adjacent listeners (likely - a no-op in current acdream — the collision broadcaster doesn't - yet exist), nuke any in-flight MoveTo (likely none for - remotes). - - Hard-snap `rmState.Body.Position = worldPos`, - `rmState.Body.Orientation = rot` (already done). - - Force `rmState.CellId = p.LandblockId` (already done). -4. FORCE_POSITION_TS: only relevant for our local player (handled - via the BlipPlayer-equivalent in PlayerMovementController, not - through the remote path). - -The change is small: extend `EntityPositionUpdate` with the three -trailing u16s, store the per-remote `TeleportTimestamp` on -`RemoteMotionState`, and gate Branch A on its advance. - ---- - -## 9. Answers to the cross-questions - -### Q1. What sequence numbers does ACE actually broadcast in 0xF748 packets? -**A.** Four u16s in this order: `InstanceSequence`, `PositionSequence`, -`TeleportSequence`, `ForcePositionSequence`. PositionSequence advances -on every `SendUpdatePosition` call (always next). InstanceSequence and -ForcePositionSequence stay constant in normal motion (current). The -**TeleportSequence advances ONLY when `adminMove=true`**, which in -practice means "GM teleported this non-player object" or — for the -local player — when ACE chains a `GameMessagePlayerTeleport (0xF751)` -**before** the `SendUpdatePosition`, advancing the player's own -ObjectTeleport seq so the next 0xF748 carries the new value. -(`Player_Location.cs:686-694`.) - -### Q2. Does TELEPORT_TS only advance on actual teleports, or every position update? -**A.** Only on actual teleports. ACE: `adminMove ? -GetNextSequence(ObjectTeleport) : GetCurrentSequence(ObjectTeleport)`. -Retail's `MoveOrTeleport` is consequently the standard -"normal-motion" path 99% of the time and only triggers Branch A on -genuine teleport events. This is why decompilers historically named -the field "teleport_timestamp" — it's a teleport flag, not a tick. - -### Q3. Do we have a teleport_timestamp field anywhere in acdream that's already plumbed but unused? -**A.** Yes — partially. `UpdatePosition.Parsed.TeleportSequence` -exists at `Messages/UpdatePosition.cs:69` and is read at line 157. -It's then used for the **local player's outbound** packet stamping -(`WorldSession._teleportSequence` ⇒ MoveToState builders). For -**remote entities**, the stamp is **dropped** at the -`PositionUpdated?.Invoke(new EntityPositionUpdate(...))` boundary -(`WorldSession.cs:712-716`) — `EntityPositionUpdate` has no -TeleportSequence field. The L.3 follow-up needs to add that field -and a per-`RemoteMotionState` `TeleportTimestamp` cache. - -### Q4. What test cases trigger Branch A in retail? -**A.** -1. **Player portal travel**: another player walks into a portal next - to you. Their character's `update_times[TELEPORT_TS]` advances - via `GameMessagePlayerTeleport (0xF751)` server-side; the - immediately-following 0xF748 carries the new TeleportSequence. -2. **Recall spells** (Lifestone Recall, Primary Portal Recall, etc.): - same path as #1. -3. **Death/Lifestone respawn**: PlayerTeleport→UpdatePosition pair. -4. **GM `@teleto` of a non-player object** (creature, item): - server-side `SendUpdatePosition(adminMove: true)`. -5. **First UP on a freshly-attached remote with `cell == 0`**: the - `cell == 0` clause in `MoveOrTeleport` line 284327 forces - Branch A for the bootstrap placement, even with stamp equality. - This is acdream's "first-UP seed" case — already handled - correctly by the `LastServerPosTime > 0` predicate at - `GameWindow.cs:3563`, but the rationale matches retail. - ---- - -## Appendix A — additional symbols - -| Function / Type | Address | Line in retail decomp | -|----|----|----| -| `CPhysicsObj::newer_event` | `0x00451b10` | 90712 | -| `CPhysicsObj::teleport_hook` | `0x00514ed0` | 283115 | -| `SmartBox::HandleReceivedPosition` | `0x00453fd0` | 92896 | -| `SmartBox::UnpackPositionEvent` | `0x004542c0` | 93055 | -| `CPhysicsObj::SetPosition (outer)` | `0x005160c0` | 284137 | -| `CPhysicsObj::SetPositionInternal (flag-decode)` | `0x00516040` | 284117 | -| `CPhysicsObj::SetPositionInternal (middle)` | `0x00515bd0` | 283892 | -| `CPhysicsObj::SetPositionInternal (inner)` | `0x00515330` | 283399 | -| `CPhysicsObj::CheckPositionInternal` | `0x00511e90` | 280070 | -| `CPhysicsObj::SetScatterPositionInternal` | `0x00515f00` | 284059 | -| `enum PhysicsTimeStamp` | — | acclient.h:6084 | -| `struct PositionPack` | — | acclient.h:53280 | -| `struct SetPositionStruct` | — | acclient.h:52398 | - -## Appendix B — flag-bit summary card - -``` -SetPositionStruct.flags (uint32) - -bit 0 0x0001 ? (single-bit enter_world default; likely SLIDE) -bit 1 0x0002 PLACEMENT fresh placement vs. continuation -bit 4 0x0010 PLACEMENT_ALLOW_SLIDING sphere slides during search -bit 5 0x0020 DO_NOT_LOAD_CELLS keep cells unloaded -bit 8 0x0100 ALLOW_SCATTER_FALLBACK retry scatter on failure -bit 9 0x0200 SCATTER initial scatter placement -bit 11 0x0800 IS_PORTAL_TRAVEL (per ACE; not in MoveOrTeleport paths) -bit 12 0x1000 SEND_POSITION_EVENT broadcast pos to cmdinterp - -Combined values: -0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT - used by MoveOrTeleport Branch A (teleport) & SetPositionSimple(slide=1) -0x1002 = SEND_POSITION_EVENT | PLACEMENT - used by SetPositionSimple(slide=0) — non-MoveOrTeleport call sites -0x0011 = PLACEMENT_ALLOW_SLIDING | (bit 0) - used by enter_world(arg2 != 0) & reenter_visibility -0x0001 = (bit 0) - used by enter_world(arg2 == 0) — bare entry -``` diff --git a/docs/research/2026-05-04-l3-port/13-cycle-picker.md b/docs/research/2026-05-04-l3-port/13-cycle-picker.md deleted file mode 100644 index 9ebc4a6c..00000000 --- a/docs/research/2026-05-04-l3-port/13-cycle-picker.md +++ /dev/null @@ -1,598 +0,0 @@ -# 13 — Retail's cycle decision tree - -**Question**: When `InterpretedMotionState` has simultaneous -`forward_command=RunForward` + `sidestep_command=SidestepRight` + -`turn_command=TurnLeft`, **what cycle plays in retail?** - -**Answer (TL;DR)**: All three. Retail does **not** pick "the winning -substate" out of a 3-axis state. Instead, `apply_interpreted_movement` -issues **three separate `DoInterpretedMotion` calls** — -forward-cmd, then sidestep-cmd, then turn-cmd — each landing in -`CMotionTable::GetObjectSequence`, which dispatches by command **class -bits** (0x40000000 substate / 0x10000000 action / 0x20000000 modifier) -to **either replace the substate or attach a modifier**. Forward goes -into the substate slot; sidestep+turn go into the modifier list. The -`CSequence` is rebuilt with all three layers via `add_motion`. - -This means **acdream's "priority winner" picker is wrong** — and so is -the `RunForward → WalkForward → Ready` fallback chain. Retail has no -fallback chain; it just calls `GetObjectSequence` per axis and ignores -NULL results. - ---- - -## A. Top of the call tree — `CMotionInterp::apply_interpreted_movement` - -Line **305713–305788** (`acclient_2013_pseudo_c.txt`), address `00528600`. - -```c -void CMotionInterp::apply_interpreted_movement(this, arg2, arg3) { - if (!physics_obj) return; - MovementParameters var_2c; // 305719 - MovementParameters::MovementParameters(&var_2c); - - // Sync run-rate from forward_speed if running - if (interpreted_state.forward_command == 0x44000007 /*RunForward*/) - my_run_rate = (float)interpreted_state.forward_speed; // 305722 - - // 1) Always re-issue current_style (e.g. CombatMode_NonCombat) - DoInterpretedMotion(this, interpreted_state.current_style, &var_2c); // 305724 - - // 2) Forward axis - if (!contact_allows_move(this, interpreted_state.forward_command)) { - var_18_2 = 0x3f800000; // 1.0f speed - DoInterpretedMotion(this, 0x40000015 /*Stand*/, &var_2c); // 305729 - } else if (standing_longjump) { - DoInterpretedMotion(this, 0x41000003 /*Ready*/, &var_2c); // 305738 - StopInterpretedMotion(this, 0x6500000f /*Sidestep*/, &var_2c); - } else { - DoInterpretedMotion(this, interpreted_state.forward_command, &var_2c); // 305744 - - // 3) Sidestep axis - if (interpreted_state.sidestep_command == 0) - StopInterpretedMotion(this, 0x6500000f /*Sidestep*/, &var_2c); // 305748 - else - DoInterpretedMotion(this, interpreted_state.sidestep_command, &var_2c); // 305752 - } - - // 4) Turn axis - if (interpreted_state.turn_command != 0) - DoInterpretedMotion(this, interpreted_state.turn_command, &var_2c); // 305762 - else - // No turn — explicitly stop any prior TurnLeft modifier - StopInterpretedMotion(this, 0x6500000d /*TurnLeft*/, &var_2c); // implied 305770 -} -``` - -**Critical observation**: this is **four `DoInterpretedMotion` calls -per UM** (current_style, forward, sidestep, turn). Each one is a -distinct `MotionTableManager::PerformMovement` → `CMotionTable::DoObjectMotion` -→ `CMotionTable::GetObjectSequence` round-trip. The composite cycle is -the sum of four state-machine transitions, not the result of a -priority pick. - -`apply_interpreted_movement` is invoked by `apply_current_movement` -(305838–305857). `apply_current_movement` is called by every UM -arrival on the player or remote object after `RawMotionState::ApplyMotion` -(or `InterpretedMotionState::ApplyMotion`) populates the state struct. - ---- - -## B. The state-routing dispatcher — `RawMotionState::ApplyMotion` - -Line **293630–293703**, address `0051eb60`. This is the **retail -analog of acdream's `AnimationCommandRouter.Classify`**. - -```c -void RawMotionState::ApplyMotion(this, arg2, arg3) { - // arg2 = motion command (e.g. 0x44000007 RunForward) - // arg3 = MovementParameters (speed, hold_key_to_apply, etc.) - - if ((arg2 - 0x6500000d) > 3) { // not Turn/Sidestep range - if ((arg2 & 0x40000000) == 0) { // not substate - if (arg2 >= 0) { - if ((arg2 & 0x10000000) != 0) // ACTION class - AddAction(this, arg2, ...); // 293640 → action queue - } else if (current_style != arg2) { // STYLE change - forward_command = 0x41000003; // Ready - current_style = arg2; // 293645 - } - } else if (arg2 != 0x44000007 /*RunForward*/) { - // 0x40000000-class but NOT RunForward (i.e. WalkForward, - // BackForward etc) goes into FORWARD slot - forward_command = arg2; // 293650 - forward_holdkey = arg3->hold_key_to_apply; - forward_speed = arg3->speed; - } - return; - } - - switch (arg2) { // 293666 - case 0x6500000d /*TurnLeft*/: - case 0x6500000e /*TurnRight*/: - turn_command = arg2; // 293671 - turn_holdkey = arg3->hold_key_to_apply; - turn_speed = arg3->speed; - return; - case 0x6500000f /*SidestepRight*/: - case 0x65000010 /*SidestepLeft*/: - sidestep_command = arg2; // 293688 - sidestep_holdkey = arg3->hold_key_to_apply; - sidestep_speed = arg3->speed; - return; - } -} -``` - -**Routing classes** (matches `0x40000000`/`0x10000000`/`0x20000000` mask -checks, also visible in `CMotionTable::GetObjectSequence`): - -| Class bit | Range | Slot | Effect | -|---|---|---|---| -| `0x40000000` | substate (e.g. `0x44000007` RunForward, `0x40000015` Stand) | `forward_command` (or replaces substate) | replaces previous substate; modifiers may be cleared | -| `0x10000000` | action (e.g. emote) | `action_head` queue | overlay; substate cycle keeps running | -| `0x20000000` | modifier | `modifier_head` list | overlay; substate cycle keeps running | -| `0x6500000d-10` | turn/sidestep (special-cased) | `turn_command` / `sidestep_command` | dedicated slots (effectively modifiers) | -| `< 0` (`0x80...`) | style change | `current_style` | full reset, forward_command → Ready | -| `0x44000007` | **RunForward** is special-cased OUT of the forward slot here — see below | — | not stored in `forward_command` directly by `RawMotionState`; it's the result of `adjust_motion` running on `WalkForward + HoldKey.Run` | - -(The InterpretedMotionState equivalent at line 293531 is functionally -the same with one extra branch — `current_style` initialization.) - ---- - -## C. `adjust_motion` — the `WalkForward + Run` → `RunForward` transform - -Line **305343–305400**, address `00528010`. This is what `DoMotion` -calls before `DoInterpretedMotion` to translate a raw key event into -a substate. - -```c -void CMotionInterp::adjust_motion(this, arg2 /*&cmd*/, arg3 /*&speed*/, arg4 /*hold_key*/) { - if (weenie_obj == 0 || weenie_obj->IsCreature()) { - switch (*arg2) { - case 0x65000010 /*SidestepLeft*/: - *arg2 = 0x6500000f; // collapse Left → Right - *arg3 *= -1; // with negative speed - // fallthrough - case 0x6500000f /*SidestepRight*/: - // Sidestep speed-mod: (3.12/1.25) * 0.5 = 1.248 - *arg3 = (3.12f / 1.25f) * 0.5f * (*arg3); - break; - case 0x6500000e /*TurnRight*/: - *arg2 = 0x6500000d; // collapse Right → Left - *arg3 *= -1; // with negative speed - break; - case 0x45000006 /*WalkBackward*/: - *arg2 = 0x45000005; // collapse to BackForward - *arg3 = -0.65f * (*arg3); - break; - case 0x44000007 /*RunForward*/: - // already a run cmd — fall through to apply_run_to_command - break; - } - - // Then: if hold_key == HoldKey_Run, escalate to RunForward - HoldKey current = arg4 == HoldKey_Invalid ? raw_state.current_holdkey : arg4; - if (current == HoldKey_Run) - apply_run_to_command(this, arg2, arg3); - } -} -``` - -`apply_run_to_command` (line 305062, addr `00527be0`): - -```c -void CMotionInterp::apply_run_to_command(this, arg2, arg3) { - float run_rate = weenie_obj ? weenie_obj->InqRunRate() : my_run_rate; - - if (*arg2 == 0x45000005 /*WalkForward*/) { - if (*arg3 != 0) - *arg2 = 0x44000007; // → RunForward - *arg3 *= run_rate; // speed *= runRate (e.g. 2.94) - } else if (*arg2 == 0x6500000d /*TurnLeft*/) { - *arg3 *= 1.5f; // turn 1.5x while running - } else if (*arg2 == 0x6500000f /*SidestepRight*/) { - *arg3 *= run_rate; - // clamp to ±3 m/s - if (fabs(*arg3) > 3.0f) - *arg3 = (sign(*arg3)) * 3.0f; - } -} -``` - -So **the way `RunForward` gets into `forward_command` in retail is**: - -1. Wire UM has `cmd=WalkForward (0x45000005)` + `hold_key=HoldKey_Run` -2. `DoMotion(0x45000005, params)` is called. -3. `adjust_motion` swaps `cmd → 0x44000007 RunForward`, `speed *= runRate`. -4. `RawMotionState::ApplyMotion(0x44000007, ...)` runs. The - special-case `arg2 != 0x44000007` branch at line 293648 means - RunForward is **NOT** stored in `forward_command` here. (This - appears intentional — `RunForward` is the post-`adjust_motion` - form; the persistent `RawMotionState` keeps the original - WalkForward.) -5. **InterpretedMotionState** stores the post-adjust value because - `apply_raw_movement` (305817) copies `raw_state.*` then runs - `adjust_motion` over each of the three axes (305829-305831) before - `apply_interpreted_movement` consumes it. - -ACE matches this: it auto-upgrades `WalkForward + HoldKey.Run` → -`RunForward` on the **outbound** wire to remote observers, which is -why our inbound parser sees `fwd=0x07` for "remote is running." - ---- - -## D. The cycle-decision core — `CMotionTable::GetObjectSequence` - -Line **298636–298950**, address `00522860`. This is where a single -motion command lands and the `CSequence` is rebuilt. It is invoked once -per `DoInterpretedMotion` call. - -Signature: -```c -int CMotionTable::GetObjectSequence( - this, - uint32_t motion, // arg2 — the command - MotionState* state, // arg3 — table-internal state - CSequence* sequence, // arg4 — the part-array sequence to mutate - float speed_mod, // arg5 - uint32_t* num_anims_out, // arg6 - int32_t force_flag); // arg7 — re-modify recursion guard -``` - -**Three dispatch branches based on the high-bit class of `motion`**: - -### D.1 — `motion < 0` (style change, e.g. `0x80000003D`) - -Lines 298661–298735. Substate's effect: reset to default substate of -the new style, optionally clear modifiers, replace cycles. - -### D.2 — `motion & 0x40000000` (substate) - -Lines 298737–298848. The **forward-axis path**. - -```c -if ((motion & 0x40000000) != 0) { // 298737 - uint32_t key = (motion & 0xffffff); - MotionData* incoming = LongHash::lookup(&this->cycles, (state->style << 0x10) | key); - if (incoming == 0) - incoming = LongHash::lookup(&this->cycles, (this->default_style << 0x10) | key); // fallback to default style - if (incoming != 0 && is_allowed(this, motion, incoming, state)) { - // Same-cycle re-speed shortcut: we're already on this cycle - // and just changing speed (e.g. forward_speed delta) - if (motion == state->substate && - same_sign(speed_mod, state->substate_mod) && - sequence->has_anims()) { - change_cycle_speed(sequence, incoming, state->substate_mod, speed_mod); - subtract_motion(sequence, incoming, state->substate_mod); - combine_motion(sequence, incoming, speed_mod); - state->substate_mod = speed_mod; - return 1; - } - - // Full transition: clear-anims + (link from current substate) + (incoming) - if (incoming->bitfield & 1) - state->clear_modifiers(); // some cycles clear modifiers on entry - - MotionData* link = get_link(this, state->style, state->substate, state->substate_mod, motion, speed_mod); - // (with two-stage fallback through default_substate if direct link missing) - - sequence->clear_physics(); - sequence->remove_cyclic_anims(); - // If no direct link, route through default substate - add_motion(sequence, link, ...); // transition anim - add_motion(sequence, incoming, speed_mod); // new cycle - - // Re-add prior substate as a modifier if it had the 0x20000000 flag - if (state->substate != motion && (state->substate & 0x20000000)) - state->add_modifier_no_check(state->substate, state->substate_mod); - - state->substate_mod = speed_mod; - state->substate = motion; - re_modify(this, sequence, state); // re-attach all modifiers - return 1; - } -} -``` - -**Key takeaway**: if the cycle-bound lookup `LongHash::lookup(&cycles, -(style<<16)|key)` returns NULL **and** the default-style fallback also -returns NULL, retail returns 0 (failure) and the call has **no effect**. -There is **no `RunForward → WalkForward → Ready` fallback chain** — that -is purely an acdream artifact. - -### D.3 — `motion & 0x10000000` (action, e.g. emote) - -Lines 298850–298907. **Overlay path**: - -```c -if ((motion & 0x10000000) != 0) { - uint32_t key = (state->style << 0x10) | (state->substate & 0xffffff); - MotionData* current_substate_md = LongHash::lookup(&this->cycles, key); - if (current_substate_md != 0) { - MotionData* link = get_link(this, state->style, state->substate, state->substate_mod, motion, speed_mod); - if (link != 0) { - state->add_action(motion, speed_mod); // append to action queue - sequence->clear_physics(); - sequence->remove_cyclic_anims(); // remove looping anims - add_motion(sequence, link, speed_mod); // transition anim (one-shot) - add_motion(sequence, current_substate_md, state->substate_mod); // re-add substate cycle! - re_modify(this, sequence, state); - return 1; - } - } -} -``` - -**Crucial: actions DO NOT replace the substate cycle.** They prepend a -one-shot link animation, then re-add the current substate cycle so it -keeps looping after the action. Acdream's "Action route" is correct in -spirit but should preserve the running cycle exactly like this. - -### D.4 — `motion & 0x20000000` (modifier — turn, sidestep, all overlay cycles) - -Lines 298909–298945. **Modifier list overlay**: - -```c -if ((motion & 0x20000000) != 0) { - // current substate must be a non-OneShot cycle - MotionData* current_substate_md = LongHash::lookup(&this->cycles, (state->style << 0x10) | (state->substate & 0xffffff)); - if (current_substate_md != 0 && (current_substate_md->bitfield & 1) == 0) { - // Look up the modifier cycle - MotionData* mod_md = LongHash::lookup(&this->modifiers, (state->style << 0x10) | (motion & 0xffffff)); - if (mod_md == 0) - mod_md = LongHash::lookup(&this->modifiers, (motion & 0xffffff)); // default-style fallback - - if (mod_md != 0) { - int rc = state->add_modifier(motion, speed_mod); // adds to modifier_head list - if (rc == 0) { - // already has a modifier with this motion — stop it and re-add - StopSequenceMotion(this, motion, 1.0f, state, sequence, &num_out); - rc = state->add_modifier(motion, speed_mod); - } - if (rc != 0) { - combine_motion(sequence, mod_md, speed_mod); // BLEND velocity/omega into sequence - return 1; - } - } - } -} -``` - -**`combine_motion`** (line 298472, addr `00522580`) — adds the modifier's -velocity AND omega into the existing sequence via -`CSequence::combine_physics`. So **turn modifiers contribute their omega -on top of the substate's velocity**. This is how retail composes -"running while turning while strafing": three layers of physics -contributions in the same `CSequence`, animated by whichever layers -brought animations in. - ---- - -## E. `is_allowed` — the gating predicate - -Line **298526–298548**, address `005226c0`. Determines whether an -incoming substate is legal in the current state. - -```c -int CMotionTable::is_allowed(this, motion, motion_data, state) { - if (motion_data == 0) return 0; - if ((motion_data->bitfield & 2) != 0) { // requires "default substate" - if (motion != state->substate) { - // Look up the default substate for this style; legal only if state is in it - uint32_t default_substate; - LongNIValHash::lookup(&style_defaults, state->style, &default_substate); - return (default_substate == state->substate) ? 1 : 0; - } - } - return 1; -} -``` - -So a substate transition that requires the "ready" state (bitfield bit -1) will **fail** if the player is currently in a non-default substate. -This is the retail-correct way to block (for example) a Sit cycle -mid-Run — not a custom acdream "skip if airborne" hack. - ---- - -## F. `re_modify` — the "re-attach modifiers after substate change" - -Line **298300–298328**, address `005222e0`. After a substate transition -that may have cleared modifiers, this walks the modifier list and -re-applies each via `GetObjectSequence`: - -```c -void CMotionTable::re_modify(this, sequence, state) { - if (state->modifier_head == 0) return; - MotionState backup; // 298308 - MotionState::MotionState(&backup, state); - while (i != 0) { - MotionList* mod = state->modifier_head; - uint32_t motion = mod->motion; - float speed = mod->speed_mod; - state->remove_modifier(mod, NULL); - backup.remove_modifier(i, NULL); - GetObjectSequence(this, motion, state, sequence, speed, &num_out, 0); // recurse - } - backup.~MotionState(); -} -``` - -This is why turn + sidestep persist across forward-cycle transitions -(WalkForward → RunForward) — they are stored in `modifier_head` and -get re-blended every time the substate changes. - ---- - -## G. Final critical answers - -### G.1 — When `forward=RunForward` + `sidestep=SidestepRight` + `turn=TurnLeft` arrive in one UM, what cycle plays? - -**All three layered.** Specifically, after `apply_interpreted_movement` -processes the UM: - -1. `DoInterpretedMotion(current_style)` — re-asserts style; usually - no-op if unchanged. -2. `DoInterpretedMotion(0x44000007 RunForward, speed=runRate*1.0)` — - `GetObjectSequence` takes the **substate** path (D.2). Replaces - prior substate. `state->substate = RunForward`. -3. `DoInterpretedMotion(0x6500000f SidestepRight, speed=1.248)` — - `GetObjectSequence` takes the **modifier** path (D.4). Adds to - `modifier_head`, calls `combine_motion` to blend sidestep velocity - into the running `CSequence`. **Substate cycle is unchanged** (still - RunForward). -4. `DoInterpretedMotion(0x6500000d TurnLeft, speed=1.5)` — same as 3 - but for turn. Blends turn omega into the sequence. - -**Visual result**: the RunForward animation cycle plays. Sidestep and -turn contribute velocity/omega only (their cycles are typically motion- -data with `velocity != 0` and `omega != 0` but `num_anims == 0` — -they're physics-only modifiers that don't override the running anim). -Some MotionTables may have animation content on sidestep/turn modifiers -for emphasis, in which case the bones get an additive blend. - -### G.2 — Substate winner pick, sequential SetCycle, or Frame-level composition? - -**Sequential `GetObjectSequence` calls per axis** (current_style → -forward → sidestep → turn), each mutating the same `CSequence` via: -- substate: `clear_physics + remove_cyclic_anims + add_motion(link) + add_motion(new)` (replace) -- modifier: `combine_motion` (additive blend) + `state->add_modifier` (track for re_modify) -- action: `clear_physics + remove_cyclic_anims + add_motion(link) + add_motion(current_substate)` (overlay-with-restore) - -The final result is a single `CSequence` carrying: -- One looping substate cycle (animation + velocity/omega contribution) -- Zero or more queued action cycles (one-shot anims; auto-pop via - `MotionState::remove_action_head` on completion) -- Zero or more modifier cycles (additive velocity/omega; usually no - animation content) - -There is **no priority pick**. There is **no Frame-level layering** — -all three are blended into the single `CSequence`'s velocity/omega -fields by `add_motion`/`combine_motion` and the result is integrated -once per physics tick. - -### G.3 — Does the `RunForward → WalkForward → Ready` fallback chain exist? - -**No.** `GetObjectSequence` has only one fallback: when the cycle for -the current style isn't found, fall back to `default_style`'s version -(line 298842 in style-change branch, line 298872-298886 in action -branch via `style_defaults` lookup). If neither exists, return 0 and -the call has no effect. - -The acdream fallback (`RunForward → WalkForward → Ready`) is a -**port artifact** that papers over the fact that we're not using -`MotionTableManager` — we're synthesizing cycle-anim association -directly from a hardcoded enum. **In a faithful port this fallback -goes away.** - -### G.4 — For Action overlay packets, does retail leave the substate cycle running? - -**Yes, exactly.** D.3 above: -```c -add_motion(sequence, link, speed_mod); // one-shot transition -add_motion(sequence, current_substate_md, state->substate_mod); // re-add running cycle -``` - -The `MotionState::action_head` queue tracks the active actions; the -sequence has both the action's transition anim AND the substate cycle -re-applied. When the action's one-shot anim completes, -`CSequence::CheckForCompletedMotions` (in `CPhysicsObj`) pops the -action and re-runs `apply_interpreted_movement` to restore pure -substate state. - ---- - -## H. Acdream port implications - -1. **Delete the priority cycle-picker** in `OnLiveMotionUpdated`. Replace - with a faithful port of `apply_interpreted_movement`: 4 sequential - `MotionTableManager.PerformMovement` calls (current_style, forward, - sidestep, turn) per UM. - -2. **Delete the `RunForward → WalkForward → Ready` fallback chain** - entirely. If a MotionTable doesn't have a cycle, retail just - silently fails to transition — there is no fallback. Our fallback - is masking missing animation data. - -3. **Port `MotionTableManager`** so we have an actual `MotionState` - (style + substate + substate_mod + modifier_head + action_head) - per remote object, and a `CMotionTable` lookup chain - (`cycles`/`modifiers`/`links`/`style_defaults`). The current - approach of "pick one cycle per UM and play it" cannot represent - modifier overlay correctly. - -4. **Run-detection: WalkForward+HoldKey.Run → RunForward** must happen - in `adjust_motion` BEFORE the routing. Acdream's - `AnimationCommandRouter.Classify` runs after this transform — - correct in concept, but only if our outbound and inbound both - apply the transform consistently. (ACE does this on the outbound, - so inbound `0x07 RunForward` is post-adjusted.) - -5. **Modifier physics**: `combine_motion` blends velocity AND omega - into a single `CSequence`. Acdream's `ObservedOmega` workaround - (audit doc 06 line 83) is a symptom of not blending omega into - the per-tick velocity properly. Once `MotionTableManager` is - ported, omega comes from `combine_motion` of TurnLeft's modifier - cycle and the `update_object` MinQuantum hack disappears. - -6. **Sidestep direction collapse**: retail collapses - `SidestepLeft → SidestepRight (negative speed)` and - `TurnRight → TurnLeft (negative speed)` in `adjust_motion`. The - modifier list keys on the collapsed form. Acdream must do the - same to match the modifier-table lookups. - ---- - -## I. Citation index - -| Function | Address | File line | -|---|---|---| -| `CMotionInterp::DoMotion` | `00528d20` | 306159 | -| `CMotionInterp::DoInterpretedMotion` | `00528360` | 305575 | -| `CMotionInterp::adjust_motion` | `00528010` | 305343 | -| `CMotionInterp::apply_run_to_command` | `00527be0` | 305062 | -| `CMotionInterp::apply_interpreted_movement` | `00528600` | 305713 | -| `CMotionInterp::apply_raw_movement` | `005287e0` | 305817 | -| `CMotionInterp::apply_current_movement` | `00528870` | 305838 | -| `CPhysicsObj::DoInterpretedMotion` | `0050ea70` | 276348 | -| `CPartArray::DoInterpretedMotion` | `00518750` | 286772 | -| `MotionTableManager::PerformMovement` | `0051c0b0` | 290906 | -| `MotionTableManager::initialize_state` | `0051c030` | 290875 | -| `CMotionTable::GetObjectSequence` | `00522860` | 298636 | -| `CMotionTable::DoObjectMotion` | `00523e90` | 300045 | -| `CMotionTable::StopObjectMotion` | `00523ec0` | 300053 | -| `CMotionTable::StopSequenceMotion` | `00522fc0` | 298954 | -| `CMotionTable::SetDefaultState` | `005230a0` | 299004 | -| `CMotionTable::is_allowed` | `005226c0` | 298526 | -| `CMotionTable::get_link` | `00522710` | 298552 | -| `CMotionTable::re_modify` | `005222e0` | 298300 | -| `RawMotionState::ApplyMotion` | `0051eb60` | 293630 | -| `InterpretedMotionState::ApplyMotion` | `0051ea40` | 293531 | -| `MotionState::add_modifier` | `00526340` | 303081 | -| `MotionState::add_modifier_no_check` | `00525ff0` | 302772 | -| `MotionState::add_action` | `005260a0` | 302828 | -| `MotionState::clear_modifiers` | `00526070` | 302810 | -| `MotionState::remove_modifier` | `00526040` | 302794 | -| `add_motion` (free fn) | `005224b0` | 298437 | -| `combine_motion` (free fn) | `00522580` | 298472 | -| `subtract_motion` (free fn) | `00522600` | 298492 | - -| Constant | Value | Meaning | -|---|---|---| -| `0x40000000` | flag | substate class bit (forward axis) | -| `0x10000000` | flag | action class bit | -| `0x20000000` | flag | modifier class bit | -| `0x44000007` | id | RunForward substate | -| `0x45000005` | id | WalkForward substate | -| `0x45000006` | id | WalkBackward substate (collapses to BackForward) | -| `0x40000011` | id | (referenced in jump path) | -| `0x40000015` | id | Stand substate | -| `0x41000003` | id | Ready substate | -| `0x6500000d` | id | TurnLeft modifier | -| `0x6500000e` | id | TurnRight modifier (collapses to TurnLeft) | -| `0x6500000f` | id | SidestepRight modifier | -| `0x65000010` | id | SidestepLeft modifier (collapses to SidestepRight) | -| `0x6500000f` (jump-charge) | id | charge_jump cycle | -| `0x8000003d` | id | "no style" sentinel (CombatMode_NonCombat default) | diff --git a/docs/research/2026-05-04-l3-port/14-local-player-audit.md b/docs/research/2026-05-04-l3-port/14-local-player-audit.md deleted file mode 100644 index 28f3d415..00000000 --- a/docs/research/2026-05-04-l3-port/14-local-player-audit.md +++ /dev/null @@ -1,722 +0,0 @@ -# 14 — Acdream audit: LOCAL player motion (the actor side) - -Date: 2026-05-04. Scope: every file that touches our own `+Acdream` -character's motion — the simulator that reads keyboard input, drives -`PhysicsBody` + `MotionInterpreter`, animates the player entity, and -broadcasts MoveToState / AutonomousPosition / JumpAction over the wire. -Counterpart to `06-acdream-audit.md` (remote / observed motion). - -Inputs read in detail: - -- `src/AcDream.App/Input/PlayerMovementController.cs` (885 LOC) -- `src/AcDream.App/Input/PlayerModeAutoEntry.cs` -- `src/AcDream.Core/Physics/PlayerWeenie.cs` (81 LOC) -- `src/AcDream.Core/Physics/MotionInterpreter.cs` — local-player call - sites (`apply_current_movement`, `DoMotion`, `DoInterpretedMotion`, - `StopInterpretedMotion`, `LeaveGround`, `HitGround`, `jump`, - `get_jump_v_z`, `get_state_velocity`). -- `src/AcDream.Core.Net/WorldSession.cs` — outbound sequence counters, - `NextGameActionSequence`, `SendGameAction` plumbing. -- `src/AcDream.Core.Net/Messages/MoveToState.cs` (165 LOC) — 0xF61C - packet builder. -- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` (89 LOC) — - 0xF753 packet builder. -- `src/AcDream.Core.Net/Messages/JumpAction.cs` — 0x... jump packet. -- `src/AcDream.App/Rendering/GameWindow.cs` — local-player wiring at - L5182–5337 (per-frame Update + outbound), L6956–7103 - (`UpdatePlayerAnimation`), L2638–2656 (server RunRate echo path), - L7997–8062 (player-controller construction at world-entry). - -Verdict labels: **PORT** (faithful retail port), **HACK** -(acdream-original logic, not in retail), **BROKEN** (regressed/wrong -vs the named-retail spec), **DIAG** (instrumentation only). - -Retail counterparts cited from -`docs/research/named-retail/acclient_2013_pseudo_c.txt`. - ---- - -## 1. `PlayerMovementController.cs` — top-level architecture - -### 1.1 Structure (L89–230) - -| Field | Verdict | Notes | -|---|---|---| -| `PhysicsBody _body` | PORT | Constructed with `Gravity \| ReportCollisions` — **NOTE** retail's local player also has Contact+OnWalkable on the transient side after first SetPosition. | -| `MotionInterpreter _motion` | PORT | Wires Body+Weenie. | -| `PlayerWeenie _weenie` | PORT-with-issue | RunSkill/JumpSkill from env vars (default 200 / 300). See §4. | -| `PhysicsEngine _physics` | PORT | Used for `ResolveWithTransition` collision sweep. | -| `Yaw`, `MouseTurnSensitivity` | HACK | Per-controller yaw float; retail stores body Orientation directly. Acdream maintains Yaw separately and rebuilds Orientation each frame (L322). | -| `StepUpHeight` / `StepDownHeight` | PORT | 0.4 m default; updated from `Setup.StepUpHeight` at world-entry (L8021–8035). Retail-faithful values. | -| `_jumpCharging` / `_jumpExtent` / `JumpChargeRate` | PORT-with-twist | 2.0/s charge rate (retail divisor unrecovered from PDB; comment at L150–155 acknowledges this is a feel-tune). The vz formula is byte-exact to retail. | -| `_wasAirborneLastFrame` | PORT | Used for justLanded edge detection. | -| `_prev*` previous-frame command/speed snapshots | PORT-ish | Used to detect `MotionStateChanged`. Retail's `CommandInterpreter::SendMovementEvent` similarly diff-gates outbound MTS. **NOTE** retail compares the entire `RawMotionState` not just selected fields — see #2.2 below. | -| `_heartbeatAccum`, `HeartbeatInterval=1.0s` | PORT | Matches retail trace 2026-05-01 + holtburger AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL (1 Hz). **Better than the prior 200 ms guess** in CLAUDE.md. | -| `_physicsAccum` (L204) | PORT | L.5 retail 30 Hz physics-tick gate. Mirrors `update_object` MinQuantum behavior. Critical for slope/wall-bounce parity. | - -### 1.2 `ApplyServerRunRate(float)` (L270–274) - -Called from `OnLiveMotionUpdated` at L2643 when an inbound UM addressed -to the local player carries `MotionState.ForwardSpeed`. - -```csharp -_motion.InterpretedState.ForwardSpeed = forwardSpeed; -_motion.apply_current_movement(cancelMoveTo: false, allowJump: false); -``` - -**Verdict: HACK.** Retail's local-side flow: - -- Server sends UM with the authoritative ForwardSpeed only when - ForwardCommand changes (apply_run_to_command echoes the speed back - via the outbound MTS). -- Retail does NOT directly stuff `ForwardSpeed` into `InterpretedState` - on inbound UM — the inbound is purely a bulk-copy into a - RawMotionState that drives the *animation sequencer*, not the - velocity feed. The local player's velocity comes from - `apply_current_movement` reading the user-input-driven InterpretedState. - -This is acdream's solution to "ACE doesn't tell the client its real -RunRate at character spawn" — we adopt the first non-zero ForwardSpeed -ACE relays. Per CLAUDE.md the long-term fix is parsing -PlayerDescription's RunSkill (issue #7); the env var workaround -becomes legacy then. - -**Side effect:** calling `apply_current_movement` here re-evaluates -`get_state_velocity` and writes `body.Velocity`, which can momentarily -override an in-flight jump's airborne velocity. The `allowJump:false` -flag mitigates it but the call is still racing against integration. - -### 1.3 `SetPosition(pos, cellId)` (L276–287) - -```csharp -_body.Position = pos; -CellId = cellId; -_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable; -_body.Velocity = Vector3.Zero; -_body.LastUpdateTime = 0.0; -``` - -**Verdict: PORT.** Mirrors retail's `CPhysicsObj::SetPositionInternal` -post-snap effects (acclient_2013_pseudo_c.txt, FUN_00516330). Used on -world-entry and PortalSpace exit. - -### 1.4 `Update(float dt, MovementInput input)` — main 600-line loop - -Main structure: portal-space gate → turning → motion state machine → -jump → integrate → collision resolve → bounce → ground/landing → -outbound commands → motion-change detection → heartbeat → animation. - -#### 1.4.1 Portal-space gate (L294–307) - -**Verdict: PORT.** When `State==PortalSpace`, returns zero-movement -result. Mirrors retail's `CPhysicsObj::set_in_portal_space` early -return. - -#### 1.4.2 Turn input (L309–322) - -```csharp -if (input.TurnRight) Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; -if (input.TurnLeft) Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt; -Yaw -= input.MouseDeltaX * MouseTurnSensitivity; -_body.Orientation = Quaternion.CreateFromAxisAngle(UnitZ, Yaw - PI/2); -``` - -**Verdict: HACK.** Retail's local turn rate is `(π/2) × TurnSpeed` -(matches the omega formula in `06-acdream-audit.md` §2). acdream uses -`WalkAnimSpeed × 0.5 = 1.56 rad/s ≈ 90°/s` — coincidentally close to -retail's `π/2 ≈ 90°/s` for TurnSpeed=1.0, but the constant is wrong- -rooted (re-derivation through animation-speed table is a coincidence). - -The mouse-turn path is entirely acdream-original; retail handles mouse -look via `CommandInterpreter::IssueAxisCommand` driving turn commands, -NOT direct yaw mutation. - -#### 1.4.3 Motion state machine (L324–411) — body velocity per input - -Determines `forwardCmd` + `forwardCmdSpeed` from input, then: - -```csharp -_motion.DoMotion(forwardCmd, forwardCmdSpeed); // → InterpretedState -if (input.StrafeRight) _motion.DoInterpretedMotion(SideStepRight, 1f, …); -if (input.StrafeLeft) _motion.DoInterpretedMotion(SideStepLeft, 1f, …); -… -if (_body.OnWalkable) -{ - var stateVel = _motion.get_state_velocity(); - float localY = …, localX = …; // hand-rolled body-local velocity - _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); -} -``` - -**Verdict: HACK.** Retail's correct flow is: - -1. Input → `CMotionInterp::DoMotion` (FUN_00529930) → - `apply_run_to_command` (FUN_00527be0) → - `DoInterpretedMotion` (FUN_00528f70) → `apply_current_movement` - (FUN_00529210) → `set_local_velocity(get_state_velocity())`. - -2. `get_state_velocity` (FUN_00528960) reads InterpretedState - directly, returning body-local - `(SidestepAnimSpeed × SideStepSpeed, RunAnimSpeed × ForwardSpeed, 0)`. - -acdream calls `DoMotion` (which DOES call `apply_current_movement` -internally → writes body.Velocity correctly) — and then OVERWRITES -that body velocity at L410 with a hand-rolled local-frame vector. - -The hand-rolled block is acdream's workaround for **WalkBackward and -SideStepLeft** producing zero velocity in `get_state_velocity` because -the retail port omitted `adjust_motion` (FUN_00528010), which retail -runs *before* InterpretedState writes: - -``` - WalkBackwards → WalkForward + speed × -0.65 - SideStepLeft → SideStepRight + speed × -1 -``` - -**Critical impact for the L.3 audit:** the local player's body.Velocity -**IS** non-zero on every grounded frame (correctly so for the local -player — opposite of remotes), but it's set by *acdream's hand-rolled -block at L410 rather than by `MotionInterpreter`*. Per the L.3 spec and -named-retail source, the local-side velocity should come from -`get_state_velocity` only, after `adjust_motion` translates the -backward/strafe-left commands. - -The **right fix** is to port `adjust_motion` (FUN_00528010) into -`MotionInterpreter` and remove L378–411 entirely, letting `DoMotion` -do its thing. - -#### 1.4.4 Jump path (L413–505) - -**Verdict: PORT (algorithm) + HACK (workaround for missing adjust_motion).** - -The jump-charge logic at L420–428 (accumulate while held + on ground, -fire on release) matches retail `Event_Jump`'s charge-bar pattern. - -The fire path: -1. `_motion.jump(extent)` — validates via retail FUN_00529390. PORT. -2. `_motion.get_jump_v_z()` — reads vz before LeaveGround zeroes - extent (matches retail FUN_00529710 invocation order). PORT. -3. `_motion.LeaveGround()` — clears Contact+OnWalkable, sets Gravity, - calls get_state_velocity into body.Velocity. PORT. -4. **Then acdream re-writes `outJumpVelocity`** (L466–501) with a - manually-computed body-local jump velocity that includes - backward/strafe-left, working around the same adjust_motion gap as - §1.4.3. Hand-rolled mirror of L378–411 logic. - -The comment at L443–460 acknowledges this explicitly: "Until -adjust_motion is ported, we mirror the grounded-velocity computation." - -#### 1.4.5 Physics integration + 30 Hz gate (L507–535) - -```csharp -_physicsAccum += dt; -if (_physicsAccum > HugeQuantum) _physicsAccum = 0f; // stale -else if (_physicsAccum >= MinQuantum) -{ - float tickDt = MathF.Min(_physicsAccum, MaxQuantum); - _body.calc_acceleration(); - _body.UpdatePhysicsInternal(tickDt); - _physicsAccum -= tickDt; -} -``` - -**Verdict: PORT.** This is the L.5 retail-physics-tick gate from -2026-04-30, reverse-engineered via cdb attach to retail. Effectively -clamps physics integration to 30 Hz even at 60+ Hz render. -**Mirrors retail's `update_object` MinQuantum behavior precisely.** - -#### 1.4.6 Collision resolve (L538–574) - -```csharp -var resolveResult = _physics.ResolveWithTransition( - preIntegratePos, postIntegratePos, CellId, - sphereRadius: 0.48f, sphereHeight: 1.2f, - stepUpHeight, stepDownHeight, - isOnGround: _body.OnWalkable, - body: _body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); -``` - -**Verdict: PORT.** Sphere dimensions match retail human Setup; -`IsPlayer | EdgeSlide` matches retail PhysicsGlobals.DefaultState for -players. - -#### 1.4.7 Wall-bounce / velocity reflection (L578–686) - -Implements retail's `handle_all_collisions` velocity-reflection: -`v_new = v - (1 + elasticity) × dot(v, n) × n`. Sources cited: -acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:2656-2721. - -**Verdict: PORT-with-conservative-rule.** The `applyBounce` gating at -L638–640 is more restrictive than retail's strict -`!(prev && now && !sledding)` — acdream additionally suppresses -bounce on the airborne→grounded landing transition because the -post-reflection upward Z would defeat acdream's per-frame -`Velocity.Z<=0` landing-snap gate. Retail tolerates this because -elasticity=0.05 is visually imperceptible there; acdream's per-frame -architecture amplifies it. Documented in the comment at L630–637. - -#### 1.4.8 Ground/landing detection (L688–720) - -```csharp -if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) -{ - bool wasAirborne = !_body.OnWalkable; - _body.TransientState |= Contact | OnWalkable; - if (_body.Velocity.Z < 0f) _body.Velocity.Z = 0f; - if (wasAirborne) { _motion.HitGround(); justLanded = true; } -} -else { _body.TransientState &= ~(Contact | OnWalkable); } -``` - -**Verdict: PORT.** Mirrors retail `MoveOrTeleport` post-resolution -landing state machine + `CMotionInterp::HitGround` call on -airborne→grounded transition. - -#### 1.4.9 Outbound wire commands (L725–795) - -Builds `outForwardCmd / outForwardSpeed / outSidestepCmd / -outTurnCmd / localAnimCmd`: - -```csharp -if (input.Forward) -{ - outForwardCmd = MotionCommand.WalkForward; - if (input.Run && _weenie.InqRunRate(out var rr)) - { - outForwardSpeed = rr; - localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward - } - else { outForwardSpeed = 1.0f; localAnimCmd = MotionCommand.WalkForward; } -} -else if (input.Backward) { outForwardCmd = WalkBackward; outForwardSpeed = 1.0f; … } -``` - -**Verdict: PORT-with-ACE-quirk.** The `WalkForward + HoldKey.Run` -encoding (rather than direct `RunForward`) is documented at L26–34 of -the file: it's a workaround for ACE's `MovementData` only computing -`interpState.ForwardSpeed` for raw WalkForward/WalkBackward. ACE then -auto-upgrades to RunForward on broadcast. - -**Retail wire format: this is correct.** Retail's -`CommandInterpreter::SendMovementEvent` builds the MoveToState the -same way — sends WalkForward with HoldKey.Run for run intent, -RunForward only for explicit run-toggle. Confirmed via 2026-05-01 cdb -trace. - -The `localAnimCmd` divergence is acdream-original but **necessary** -because the local sequencer wants RunForward immediately (for visual -parity), not the wire's WalkForward. - -#### 1.4.10 Motion-change detection (L797–831) - -```csharp -bool changed = outForwardCmd != _prevForwardCmd - || outSidestepCmd != _prevSidestepCmd - || outTurnCmd != _prevTurnCmd - || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) - || runHold != _prevRunHold - || localAnimCmd != _prevLocalAnimCmd; -``` - -**Verdict: PORT-ish.** Retail's `CommandInterpreter::SendMovementEvent` -diffs against the *previously-sent* RawMotionState, sending an MTS -only when the new state is non-equal. acdream's diff is field-selective -but covers the load-bearing fields. The `localAnimCmd` field is not in -retail's RawMotionState — including it forces a fresh outbound on -Walk↔Run toggle (W held + Shift toggle), which retail also does because -the toggle changes the wire ForwardSpeed. So the net effect matches. - -**Subtle issue:** if the user only changes SidestepSpeed or TurnSpeed -(without changing the corresponding command), no outbound fires. Retail -likely doesn't either; not a regression. - -#### 1.4.11 Heartbeat (L833–845) - -```csharp -_heartbeatAccum += dt; -HeartbeatDue = _heartbeatAccum >= 1.0f; -if (HeartbeatDue) _heartbeatAccum = 0f; -``` - -**Verdict: PORT.** 1 Hz cadence matches retail trace 2026-05-01 + -holtburger. Caller (`GameWindow`) reads `HeartbeatDue` and fires -AutonomousPosition. - -**OPEN QUESTION** flagged in CLAUDE.md memory: retail's -`SendPositionEvent` gates the heartbeat on `transient_state` (must -have Contact+OnWalkable+valid Position) AND on motion. acdream's -1 Hz at-rest heartbeat is unconditional once in-world. Retail's cdb -trace showed AutonomousPosition gated on motion — i.e. **acdream -sends AP heartbeats while standing still, retail does not**. Probable -mismatch worth investigating. Filed in `project_retail_motion_outbound.md`. - ---- - -## 2. `MoveToState.cs` (Messages/MoveToState.cs, 165 LOC) - -**Verdict: PORT.** Wire layout matches holtburger -`RawMotionState::pack` + `MoveToStateActionData::pack`: - -| Field | Verdict | Notes | -|---|---|---| -| GameAction envelope (0xF7B1, seq, 0xF61C) | PORT | | -| Flags dword (bits 0–10 fields, bits 11–31 cmd-list-len=0) | PORT | We never send commands. | -| Conditional fields in flag-bit order | PORT | CurrentHoldKey, ForwardCommand, ForwardHoldKey, ForwardSpeed, SidestepCommand/HoldKey/Speed, TurnCommand/HoldKey/Speed. CurrentStyle (0x2) intentionally not sent — we don't track stance changes here (handled via separate ChangeCombatMode). | -| WorldPosition: cellId u32, x/y/z f32, rotW/rotX/rotY/rotZ f32 | PORT | Quaternion wire order W,X,Y,Z confirmed. | -| Sequences: u16 instance/serverControl/teleport/forcePosition | PORT | | -| Contact byte u8 + 4-byte align | PORT | | - -**Open question 1:** retail builds the MoveToState with the FULL -`RawMotionState` from the local CMotionInterp, including -`AftCommand`/`AftSpeed`/`AftHoldKey` axes for sailing/swimming. We -omit those flags — they're never set by `PlayerMovementController`. -For walking that's fine; if we ever ship swimming/sailing this needs -extending. - -**Open question 2:** `CurrentStyle` (0x2) — when the player changes -combat stance (e.g. Sword), does retail emit it as a separate -ChangeCombatMode + MoveToState pair, or does the MoveToState itself -carry the new style? Holtburger sends it via RawMotionState. acdream's -ChangeCombatMode path (`SendChangeCombatMode`) sends a separate -GameAction. **Cross-check needed**, but not load-bearing for the L.3 -audit. - ---- - -## 3. `AutonomousPosition.cs` (Messages/AutonomousPosition.cs, 89 LOC) - -**Verdict: PORT.** Simpler than MoveToState: GameAction envelope + -WorldPosition + 4 sequences + lastContact byte + align. - -Wire layout matches holtburger -`AutonomousPositionActionData::pack`. Used as the 1 Hz heartbeat. - ---- - -## 4. `PlayerWeenie.cs` (81 LOC) - -**Verdict: PORT (algorithm) + HACK (data source).** - -`GetRunRate(burden, runSkill)` and `GetJumpHeight(burden, jumpSkill, -extent)` formulas are byte-for-byte from decompiled `acclient.exe` + -ACE `MovementSystem.GetRunRate / GetJumpHeight`: - -``` -RunRate = (burdenMod × (runSkill / (runSkill+200) × 11) + 4) / 4 (cap 4.5 at skill 800) -JumpHeight = burdenMod × (jumpSkill/(jumpSkill+1300) × 22.2 + 0.05) × extent - (clamp to 0.35 m min) -vz = sqrt(jumpHeight × 19.6) -``` - -**HACK side:** the constructor reads RunSkill/JumpSkill from env vars -(default 200 / 300). Per CLAUDE.md these are **NOT synced to the -server** — ACE has its own canonical RunSkill which it broadcasts in -`UpdateMotion.ForwardSpeed`. We currently echo via -`PlayerMovementController.ApplyServerRunRate` (§1.2), which DIRECTLY -overwrites `InterpretedState.ForwardSpeed` rather than updating the -weenie's RunSkill. So our local-prediction velocity may diverge from -the server's authoritative value mid-tick if the server's RunRate -differs from `(loadMod × runSkill/(runSkill+200) × 11 + 4)/4`. - -**Long-term fix:** parse `PlayerDescription` (0xF7B0/0x0013), extract -RunSkill, call `_weenie.SetSkills(serverRun, serverJump)`. Filed -issue #7 in CLAUDE.md. - ---- - -## 5. `MotionInterpreter` local-player call sites - -Local-player call sites of `apply_current_movement`: - -- `PlayerMovementController.cs:273` (`ApplyServerRunRate`) — see §1.2. - HACK. -- `PlayerMovementController.cs` indirectly via `DoMotion` → - `DoInterpretedMotion` → `apply_current_movement`. PORT. -- `PlayerMovementController.cs` jump path via `LeaveGround`. PORT. -- `MotionInterpreter` internally inside `DoInterpretedMotion`, - `StopInterpretedMotion`, `HitGround`. PORT. - -For the local player **`body.Velocity` is correctly non-zero per tick** -— driven by user input. This is the OPPOSITE of the L.3 invariant for -remotes and matches the named-retail spec. - ---- - -## 6. `GameWindow.cs` local-player wiring - -### 6.1 World-entry construction (L7997–8062) - -```csharp -_playerController = new PlayerMovementController(_physicsEngine); -if (_lastSeenRunSkill > 0) _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill); -_playerController.StepUpHeight = playerSetup?.StepUpHeight ?? 0.4f; -_playerController.StepDownHeight = playerSetup?.StepDownHeight ?? 0.4f; -_playerController.SetPosition(initResult.Position, initResult.CellId); -_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); -_playerController.Yaw = rawYaw + MathF.PI/2; -``` - -**Verdict: PORT.** StepUp/Down come from Setup; cycle-velocity -accessor wires the AnimationSequencer into `MotionInterpreter`. - -### 6.2 Per-frame Update loop (L5182–5337) - -Reads input → `_playerController.Update(dt, input)` → updates entity -position+rotation → updates ChaseCamera → builds outbound MoveToState -(if MotionStateChanged) + AutonomousPosition (if HeartbeatDue) + -JumpAction (if jump fired) → calls `UpdatePlayerAnimation`. - -**Verdict: PORT.** Wire-output gating is correct: change-driven MTS, -heartbeat AP, event-driven Jump. Cell ID composition and world→wire -conversion match retail. - -The HoldKey block at L5266–5288 sends per-axis hold keys for every -active axis (forward/sidestep/turn) using the same value. Per -holtburger comment at L876: this is correct — retail uses the same -HoldKey value across all active axes. - -### 6.3 `UpdatePlayerAnimation` (L6956–7103) - -Computes the visible animation cycle from MovementResult: - -``` -Airborne → MotionCommand.Falling -Forward+Run → RunForward (LocalAnimationCommand) -Forward → WalkForward -Backward → WalkBackward -Sidestep → SideStepLeft/Right -Turn → TurnLeft/Right -else → Ready -``` - -Drives `ae.Sequencer.SetCycle(NonCombatStance, animCommand, -animSpeed × animScale, skipTransitionLink: airborne)`. - -**Verdict: PORT.** Mirrors retail `MotionTable::SetState` / -`Sequence::SetCycle`. The `LocalAnimationSpeed` decoupling -(forward+run = runRate; backward+run / strafe+run = runRate too even -though wire ForwardSpeed=1.0) is acdream-original but correct: it -ensures the visible cycle pace matches the actual movement velocity -even when the wire format keeps backward/strafe at 1.0 for ACE -compat. - -**Skip-link on Falling** (L7091): retail-faithful — without it, the -local player visibly stood still for ~100 ms at the start of every -jump while the RunForward→Falling transition link drained. - ---- - -## 7. Shared-with-remote-player code paths - -| File / function | Used by local? | Used by remote? | Concern? | -|---|---|---|---| -| `PhysicsBody` (full) | YES | YES | NO — same retail port; both sides write Velocity / Orientation correctly per their respective sources. | -| `MotionInterpreter` (full) | YES | YES | YES — `apply_current_movement` is the converging point. Local writes body.Velocity from user input (correct). Remote writes body.Velocity from `InterpretedState.ForwardCommand+ForwardSpeed` (incorrect per L.3 spec — see audit 06 §8). After L.3 lands, only the local player path will call `apply_current_movement` per tick; remotes will be anim-root-motion driven. | -| `AnimationSequencer` (full) | YES | YES | NO — both sides drive `SetCycle` from the appropriate input source. | -| `PhysicsEngine.ResolveWithTransition` | YES | YES | NO — collision sweep with sphere dims + step heights; identical for both. **Both paths must keep this** post-L.3 (audit 06 §6 step 4b). | -| `AnimatedEntity.Sequencer` | YES | YES | NO — sequencer is per-entity. Local UpdatePlayerAnimation writes its own; remote OnLiveMotionUpdated writes the remote's. Independent. | -| `PlayerWeenie` | YES | NO | NO — local-only. Remotes don't have a weenie; their MotionInterpreter fall back to default 1.0 RunRate via `if (weenie==null) x87_r7 = 1f` in retail's apply_run_to_command. | -| `PhysicsEngine.ShadowObjects` | NO | YES | NO — shadow tracking is for cell-list updates, not local-player. | -| `RemoteMotion` struct + dead-reckon table | NO | YES | NO — remote-only. | - -**Key convergence point:** `MotionInterpreter.apply_current_movement`. -Local needs it (per tick, driven by user input). Remote should NOT -call it per tick (per L.3 spec). The two paths share the function but -diverge on call frequency. - -After L.3 lands: -- Local: `_playerController.Update` → `_motion.DoMotion` → - `apply_current_movement` (writes body.Velocity from user input). - **Once per frame.** Per CLAUDE.md ACE wire-format quirks, no - changes needed. -- Remote: per-tick reads `sequencer.CurrentVelocity` directly via - `PositionManager.ComputeOffset`. **Never calls - `apply_current_movement`.** body.Velocity stays 0 for grounded - remotes. `apply_current_movement` still fires on `OnLiveMotionUpdated` - for axis-state setup, but not per tick. - ---- - -## 8. Specific question answers - -**(a) Does `PlayerMovementController` mirror retail's pipeline -(`apply_current_movement → integrate → collision sweep`)?** - -YES, partially. The pipeline order is correct: input → -DoMotion (which calls apply_current_movement internally) → integrate -(`UpdatePhysicsInternal`) → ResolveWithTransition. **But it then -*overwrites* the velocity with hand-rolled body-local code at L378–411 -to work around the missing `adjust_motion` port** for backward/ -strafe-left. The same workaround appears in the jump path at L466–501. -The pipeline shape matches retail; the velocity-source-of-truth diverges. - -**(b) Does it use `m_velocityVector` correctly? (Local DOES integrate -velocity, unlike remotes.)** - -YES — local body.Velocity is intentionally non-zero per tick, driven -by user input via `set_local_velocity`. Then -`UpdatePhysicsInternal` integrates `Position += Velocity*dt + 0.5*A*dt²`. -This matches retail's local-player model. - -**(c) Does the outbound MoveToState packet match retail's wire format?** - -YES. Wire layout in `Messages/MoveToState.cs` is byte-faithful to -holtburger's `RawMotionState::pack` + `MoveToStateActionData::pack`, -which is itself ground-truth from a working Rust client. Per-axis -HoldKey (forward/sidestep/turn) is sent. CurrentStyle (0x2) is -omitted intentionally; OK for walking. AftCommand/AftSpeed/AftHoldKey -not sent — fine until swimming/sailing ships. - -**(d) Is the local sequencer driven by `UpdatePlayerAnimation` -matching retail's `MotionTable::SetState`?** - -YES. The cycle picker (Airborne→Falling > LocalAnim > wire forward > -sidestep > turn > Ready), `NonCombatStance` overlay, sequencer -`SetCycle` invocation all mirror retail. Speed decoupling -(LocalAnimationSpeed vs wire ForwardSpeed) is acdream-original but -correct for ACE-quirk-driven backward/strafe pacing. - -**(e) Are there any acdream-specific hacks/workarounds in the local -player path?** - -YES. Five distinct hacks, all acknowledged in code comments: - -1. **Hand-rolled velocity overrides at L378–411 + L466–501** — - workaround for missing `adjust_motion` port. Both grounded and jump - paths are affected. **Top priority to fix** alongside the L.3 - refactor; same root cause as remote-side `apply_current_movement` - issues. -2. **`ApplyServerRunRate`** (§1.2) — directly stuffs server's - ForwardSpeed into InterpretedState. Should be replaced with - PlayerDescription parse → `_weenie.SetSkills(...)`. -3. **Yaw float + mouse-turn sensitivity** — retail uses turn commands - from `IssueAxisCommand` for ALL turn input (keyboard + mouse). We - maintain a separate Yaw and rebuild Orientation each frame. -4. **`JumpChargeRate = 2.0/s`** — retail divisor unrecovered from - PDB; tuned for feel. Cited in code comment at L150–155. -5. **Wall-bounce landing suppression** (L638–640) — more - conservative than retail's strict rule, justified by acdream's - per-frame architecture amplifying micro-bounce on landing. - -**(f) How is the local player's runRate sourced?** - -Three layers, in priority order: - -1. `_lastSeenRunSkill` from PlayerDescription parse (issue #7, NOT - YET WIRED — code at L1565 is dormant). -2. `ACDREAM_RUN_SKILL` env var (default 200) → constructor → weenie - formula. -3. `ApplyServerRunRate(forwardSpeed)` echo from inbound UM — - overwrites `InterpretedState.ForwardSpeed` directly, bypassing the - weenie formula. - -**Retail-faithful?** PARTIALLY. The formula -`(loadMod × runSkill/(runSkill+200) × 11 + 4)/4` is byte-exact -retail. But the data source is wrong: retail's local -`CWeenieObject::InqRunRate` reads the player's actual server-synced -RunSkill, not an env var. Until issue #7 ships, low-skill characters -(< 200) and high-skill characters (> 800) will mispredict. - -**(g) Does the local player have any `IsPlayerGuid`-style gates that -would also need cleanup?** - -The local-player path AT THE PER-FRAME UPDATE level is gated only by -`_playerMode && _playerController != null`, which is appropriate -(it's the actor side). The IsPlayerGuid gates in audit 06 -(`OnLivePositionUpdated`, `OnLiveMotionUpdated`, etc.) all SKIP -the local player guid (e.g. `if (update.Guid == _playerServerGuid) -return;` patterns) — that's correct because the local player's state -is owned by `_playerController`, not by the dead-reckoning struct. - -The only place where IsPlayerGuid logic touches local state is -`ApplyServerRunRate` (§1.2): an inbound UM addressed to the local -player echoes ForwardSpeed. That's fine; not a gate to remove. - -**No IsPlayerGuid gates to clean up on the actor side.** All cleanup -in audit 06 is on the observer/remote side. - ---- - -# Summary - -## (a) Is local player motion already retail-faithful? - -**Mostly yes, with two known divergences.** The pipeline shape -(input → DoMotion → integrate → collision sweep → outbound MTS) is -retail-faithful and well-cited. The 30 Hz physics-tick gate, jump -charge formula, wall-bounce reflection, ground/landing detection, -HoldKey wire encoding, and 1 Hz heartbeat all match named-retail and -the 2026-05-01 cdb trace. The outbound MoveToState + AutonomousPosition -packet builders are byte-faithful to holtburger's reference -implementation. - -The two divergences: - -1. **Hand-rolled velocity overrides** (L378–411, L466–501) work around - the missing `adjust_motion` (FUN_00528010) port. Backward/ - strafe-left commands are translated to WalkForward/SideStepRight - with negative speeds in retail before they reach InterpretedState; - acdream skips the translation and re-derives the velocity manually - downstream. Result is correct but architecturally diverged. - -2. **RunSkill data source** is env var (default 200) plus - ApplyServerRunRate echo, instead of the server's authoritative - PlayerDescription value. Causes mispredicted local velocity for - non-default skill characters. - -Both are pre-existing tech debt, not L.3-specific. The local-player -audit found NO L.3-introduced regressions analogous to the remote-side -`apply_current_movement`-per-tick bug. - -## (b) Top 3 things that need to change - -1. **Port `adjust_motion` (FUN_00528010) into `MotionInterpreter`.** - Translates WalkBackwards → WalkForward + speed×-0.65 and - SideStepLeft → SideStepRight + speed×-1 BEFORE InterpretedState - write. Once present, `get_state_velocity` returns correct vectors - for all motion commands and the hand-rolled overrides at L378–411 - + L466–501 can be deleted. Same fix benefits the jump path. - -2. **Wire `PlayerDescription` (0xF7B0 / 0x0013) RunSkill+JumpSkill - → `_weenie.SetSkills(...)`** (issue #7). Removes the env var - workaround and `ApplyServerRunRate` becomes a no-op (the weenie's - formula already produces the correct RunRate from the server's - skill). Velocity-prediction parity for arbitrary characters. - -3. **Rationalise the heartbeat gate.** Retail's - `SendPositionEvent` gates AutonomousPosition on motion state - (Contact + OnWalkable + active velocity); acdream sends 1 Hz - unconditionally while in-world. Cdb trace 2026-05-01 confirmed - retail does not heartbeat at rest. Filing this — wasted - bandwidth + observer dead-reckon noise. Low-stakes vs (1) and - (2), but a clean behavioral diff worth fixing. - -## (c) Shared-with-remote code paths that need to converge - -`MotionInterpreter.apply_current_movement` is the convergence point. -Local must call it per tick (correct, retail-faithful); remote must -NOT (per L.3 spec). The function itself is fine; the call-site -discipline is what matters. - -After the L.3 port: - -- `PhysicsBody`, `AnimationSequencer`, `PhysicsEngine.ResolveWithTransition`, - `MotionInterpreter` (the type, not its per-tick invocation) all stay - shared and correct. -- `apply_current_movement`: local-player call (per tick + on UM echo) - remains; remote-player per-tick call gets removed (currently at - GameWindow.cs:6599). -- The shared `CurrentVelocity` accessor on `AnimationSequencer` (wired - to local via `AttachCycleVelocityAccessor`) gets a parallel - `PositionManager.ComputeOffset` consumer for remotes — same field, - different driver. - -**No symmetry break required.** Local and remote can share the type -hierarchy; they diverge only on which functions they invoke per -tick. The L.3 port doesn't perturb the local-player path at all -beyond optionally fixing items (1)-(3) above as side improvements. - ---- - -Path: `docs/research/2026-05-04-l3-port/14-local-player-audit.md`. diff --git a/docs/research/2026-05-05-issue-42-handoff.md b/docs/research/2026-05-05-issue-42-handoff.md deleted file mode 100644 index b5c63ddf..00000000 --- a/docs/research/2026-05-05-issue-42-handoff.md +++ /dev/null @@ -1,96 +0,0 @@ -# #42 follow-up — PhysicsEngine.ResolveWithTransition airborne XY drift - -**Context:** L.3 motion port landed in this branch (commits `de129bc` -`40d88b9` `2365c8c` `d57ace0` `c26bbbb` `b37b713`) — player-remote -running/walking/strafing/NPCs all visually verified clean. M4 jump -landing was fixed (CellId update). But that fix re-enabled -`ResolveWithTransition` per-tick during airborne arcs and exposed a -pre-existing PhysicsEngine bug: stationary jumps render with ~1 m -horizontal offset from the actor's actual XY, then snap back on the -next UM/UP. - -**Confirmed root cause** (A/B-tested 2026-05-05): drift originates -inside `ResolveWithTransition`, not from wire data, local Euler error, -or stale velocity. With the sweep skipped, jumps render geometrically -correct (but body falls through floor). With it enabled, jumps land -correctly but show the drift. So the bug lives in the sweep. - -## Most likely mechanism - -**Initial-overlap depenetration along non-+Z terrain normal.** At -jump start the collision sphere is touching the floor at body Z. Most -outdoor terrain triangles have non-vertical normals (small horizontal -component). The sweep's first-frame action is to resolve the -penetration by separating the sphere along the contact normal — and -on a tilted triangle that separation has horizontal magnitude. The -body gets shoved sideways the first frame; the rest of the arc -carries the offset. - -Direction-correlation test: jump at multiple landblock positions; if -drift direction varies with terrain slope orientation (not actor -facing), this hypothesis is confirmed. - -Other candidates ranked by probability: -2. Step-down probe firing despite `isOnGround: false` parameter. -3. EdgeSlide on near-vertical motion against near-vertical surface. - -## Files to investigate - -- `src/AcDream.Core/Physics/PhysicsEngine.cs` — `ResolveWithTransition` - + any internal `CTransition` / `find_valid_position` helpers. The - initial-overlap depenetration path is the primary target. -- Reference at `docs/research/named-retail/acclient_2013_pseudo_c.txt`: - - `CTransition::find_valid_position` (called from `transition()`) - - `SpherePath` initialization - - Verbatim retail depenetration logic for airborne bodies - -If our port differs from retail in this region, that diff is the bug. - -## Fix paths (in order of preference) - -**(a) Skip initial-overlap depenetration when airborne.** Gate the -"separate from initial contact plane" step inside -`ResolveWithTransition` on `isOnGround: true`. Trusts the previous -tick's resolve to have left the body in a non-overlapping position. -Most likely correct fix. - -**(b) Zero step-up/down for airborne sweeps.** Pass -`stepUpHeight: 0f, stepDownHeight: 0f` from -`GameWindow.cs:6478+` when `rm.Airborne`. No side effects since -airborne bodies don't step. - -**(c) Stripped airborne sweep.** Replace the full sphere sweep with a -simpler vertical sphere-vs-terrain intersection + wall-collision -stop. Loses retail fidelity but eliminates all three mechanisms. -Probably overkill if (a) or (b) suffices. - -## Repro - -1. Launch acdream + retail client side-by-side on local ACE - (127.0.0.1:9000). -2. Have a retail-controlled toon stand still on outdoor terrain. -3. Jump in place. -4. Observe acdream window: arc shows ~1 m XY offset, lands offset, - snaps back on next inbound UM. - -To verify hypothesis (1) specifically: repeat the jump on terrain -patches with different visible slope orientations; if drift direction -changes accordingly, depenetration is confirmed. - -## Acceptance - -- Stationary jumps render at the actor's actual XY (no perceptible - drift, no snap-back on next UM). -- Wall-collision airborne still works (jumping into doorways, jumping - puzzles where you have to thread between platforms or through arches). - -## Operating notes - -- This is a `PhysicsEngine` bug, not a motion-port bug. The L.3 work - is done; this is a separate investigation. -- The `[VU.WIRE]` instrumentation idea from #42's earlier draft can - be skipped — we already proved wire data isn't the source via the - A/B test. -- cdb attach to retail (`docs/research/2026-05-04-l3-port/`-adjacent - toolchain documented in CLAUDE.md) is available if comparing - retail's airborne sweep behavior against ours becomes useful. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2666fb50..fdb71a92 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -295,6 +295,11 @@ public sealed class GameWindow : IDisposable /// public double LastMoveToPacketTime; /// + /// Legacy field — no longer used for slerp (retail hard-snaps + /// per FUN_00514b90 set_frame). Kept to avoid churn. + /// + public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity; + /// /// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed /// (π/2 × turnSpeed, signed). Applied per tick to body orientation /// via manual integration (bypassing PhysicsBody.update_object's @@ -329,21 +334,34 @@ public sealed class GameWindow : IDisposable /// /// Per-remote position-waypoint queue + catch-up math (retail's /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). - /// Drives per-tick body translation for grounded player remotes - /// via . + /// Replaces the hard-snap-then-Euler-extrapolate path when + /// ACDREAM_INTERP_MANAGER=1 — see Phase L.3.1 spec at + /// docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md. + /// Field exists from Task 3 onwards; consumed in Tasks 4 + 5. /// public AcDream.Core.Physics.InterpolationManager Interp { get; } = new AcDream.Core.Physics.InterpolationManager(); /// /// Per-frame combiner for animation root motion + InterpolationManager - /// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30: - /// queue catch-up REPLACES anim when active; anim stands when queue - /// is idle. + /// correction (Phase L.3.2). Consumed in TickAnimations to compute the + /// per-frame body.Position delta. /// public AcDream.Core.Physics.PositionManager Position { get; } = new AcDream.Core.Physics.PositionManager(); + /// + /// Most recent server-broadcast Z coordinate from any UpdatePosition + /// (including mid-arc airborne UPs). Used by the + /// ACDREAM_INTERP_MANAGER=1 per-tick path as a landing-fallback + /// floor: if gravity drags the body's Z below this value while + /// is still set, force-land locally because + /// the server has effectively told us where the ground is even if + /// it never sent an IsGrounded=true UP. Initialized to NaN so the + /// fallback is a no-op until the first UP arrives. + /// + public float LastServerZ = float.NaN; + /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the /// previous UpdatePosition's world position + timestamp. The per-tick @@ -1990,33 +2008,7 @@ public sealed class GameWindow : IDisposable if (spawn.SubPalettes is { } subPaletteList) { foreach (var subPal in subPaletteList) - { - int rawOffset = subPal.Offset * 8; - int rawLen = subPal.Length == 0 ? 2048 : subPal.Length * 8; - var pal = _dats.Get(subPal.SubPaletteId); - string palInfo = pal is null ? "Palette dat NOT FOUND (might be PaletteSet 0x0F?)" : $"Colors.Count={pal.Colors.Count}"; - Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} wireOffset={subPal.Offset} wireLength={subPal.Length} -> rawIdx[{rawOffset}..{rawOffset + rawLen}) {palInfo}"); - // If pal is non-null and small, show first 4 colors - if (pal is not null && pal.Colors.Count > 0) - { - int sample = Math.Min(4, pal.Colors.Count); - for (int s = 0; s < sample; s++) - { - var c = pal.Colors[s]; - Console.WriteLine($" pal[{s:D3}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - // Also probe at the rawOffset (if in range) — that's where overlay copies FROM in our code - if (rawOffset < pal.Colors.Count) - { - var c = pal.Colors[rawOffset]; - Console.WriteLine($" pal[{rawOffset:D4}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2} <-- our code reads here"); - } - else - { - Console.WriteLine($" pal[{rawOffset:D4}] OUT OF RANGE (Colors.Count={pal.Colors.Count}) -- our code's read SKIPS the overlay !!"); - } - } - } + Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); } } foreach (var change in animPartChanges) @@ -3444,27 +3436,29 @@ public sealed class GameWindow : IDisposable rmState.Body.Orientation = rot; } - // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for - // player remotes. Mirrors CPhysicsObj::MoveOrTeleport - // (acclient @ 0x00516330) — airborne no-op, far-snap, near - // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through - // the legacy synth-velocity branch below; their motion comes - // from ServerVelocity / ServerMoveTo which the legacy path - // already handles correctly. - // - if (IsPlayerGuid(update.Guid)) + // L.3.1 Task 4: env-var gated retail-faithful MoveOrTeleport routing. + // Mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). + // Enabled only when ACDREAM_INTERP_MANAGER=1 to keep default behavior + // identical to before this commit. Legacy hard-snap path remains below. + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { // Orientation always snaps on receipt — InterpolationManager walks // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; - // Adopt server's cell ID on every UP (airborne or grounded). - // Required by the legacy airborne path's per-tick - // ResolveWithTransition gate (rm.CellId != 0); without this - // an airborne player remote falls through the floor because - // the sphere sweep is skipped. Note: enabling the sweep also - // exposes a pre-existing depenetration bug — see #42. - rmState.CellId = p.LandblockId; + // Track the most recent GROUNDED server-broadcast Z. Read by + // the per-tick landing-fallback in TickAnimations: if gravity + // drags the body more than 0.5 m below this floor while still + // airborne, we force-land locally even when the server never + // sent an IsGrounded=true UP for the actual landing frame. + // + // Only updated for grounded UPs — mid-arc airborne UPs would + // raise this value to the player's peak Z, then the body's + // descent would cross (peak - 0.5) and trigger a force-land + // mid-air, producing the user-reported "small landing in the + // air before landing on the ground" when jumping while moving. + if (update.IsGrounded) + rmState.LastServerZ = worldPos.Z; // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous // server-pos snapshot forward AND print the per-UP comparison @@ -3505,15 +3499,7 @@ public sealed class GameWindow : IDisposable // integrating gravity via per-frame UpdatePhysicsInternal. Server is // authoritative for the arc; we don't predict it locally. if (!update.IsGrounded) - { - // Undo the unconditional entity hard-snap at the top of the - // function (entity.Position = worldPos): the body is mid-arc - // and TickAnimations will write entity = body next frame - // anyway. Setting entity = body now prevents a 1-frame - // teleport-to-server-then-yank-back rubber-band. - entity.Position = rmState.Body.Position; return; - } // ── LANDING TRANSITION ──────────────────────────────────────── // First IsGrounded=true UP after rmState.Airborne signals landed. @@ -3561,26 +3547,12 @@ public sealed class GameWindow : IDisposable else { // Within view bubble: enqueue waypoint for adjust_offset to walk to. - // The per-frame TickAnimations player-remote path drives the - // actual body advancement via InterpolationManager.AdjustOffset. - // Pass body's current position so the InterpolationManager can - // detect a far-distance enqueue (>100 m from body) and pre-arm - // an immediate blip — avoids body drifting visibly toward a - // far waypoint instead of teleporting to it. + // PositionManager (called per-frame in TickAnimations) handles the + // actual body advancement — mix of animation root motion + queue + // correction. float headingFromQuat = ExtractYawFromQuaternion(rot); - rmState.Interp.Enqueue( - worldPos, - headingFromQuat, - isMovingTo: false, - currentBodyPosition: rmState.Body.Position); + rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false); } - // Sync the visible entity to the body — overrides the unconditional - // entity.Position = worldPos snap at the top of this function. - // For the far-snap branch this is a no-op (body == worldPos); for - // the near-enqueue branch this prevents a 1-frame teleport-then- - // yank-back rubber-band as TickAnimations chases worldPos via the - // queue. - entity.Position = rmState.Body.Position; return; } @@ -3641,6 +3613,7 @@ public sealed class GameWindow : IDisposable // a halved "observed" rate → visible slow-start. Formula-only // is stable and simple; hard-snap fixes any drift. rmState.Body.Orientation = rot; + rmState.TargetOrientation = rot; rmState.LastServerPos = worldPos; rmState.LastServerPosTime = nowSec; // Align the body's physics clock with our clock so update_object @@ -6066,38 +6039,76 @@ public sealed class GameWindow : IDisposable && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - if (IsPlayerGuid(serverGuid) && !rm.Airborne) + if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1") { - // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ── + // ⚠️ REGRESSED 2026-05-03 — DO NOT ENABLE — see docs/ISSUES.md #40 ⚠️ // - // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + - // 04-interp-manager.md + - // 05-position-manager-and-partarray.md): + // Introduced by e94e791 (L.3.1+L.3.2 Task 3) intending to + // mirror retail CPhysicsObj::MoveOrTeleport (network-packet + // entry point — minimal work). Wrong retail function for the + // per-frame tick — the actual per-frame chain is retail's + // update_object (FUN_00515020), which the LEGACY path below + // correctly mirrors (apply_current_movement → + // UpdatePhysicsInternal → ResolveWithTransition collision + // sweep). This env-var path strips the collision sweep AND + // clears body.Velocity, leaving only PositionManager queue + // catch-up — which stair-steps with the 1 Hz UP cadence on + // slopes and produces visible position blips on flat ground. // - // - For a grounded REMOTE player, m_velocityVector stays at 0. - // - apply_current_movement is NEVER called per tick on remotes - // (it's the local-player-only velocity feed). - // - UpdatePhysicsInternal's translation step is gated on - // velocity² > 0, so it's a no-op when body.Velocity = 0. - // - ResolveWithTransition is NOT called — the server already - // collision-resolved the broadcast position. - // - Per-tick body translation per retail UpdatePositionInternal: - // 1. CPartArray::Update writes anim root motion (body-local - // seqVel × dt) into the local frame. - // 2. PositionManager::adjust_offset OVERWRITES the local - // frame's origin with the queue catch-up vector when - // the queue is active and the head is not yet reached - // — REPLACE, not additive. - // 3. Frame::combine composes the local frame with the - // body's world pose. - // Net: catch-up replaces anim during the chase phase, anim - // stands when the queue is empty / head reached. PositionManager. - // ComputeOffset implements this exact REPLACE dichotomy. + // Commit B (039149a, 2026-05-03) ported ResolveWithTransition + // here but symptom persists because body.Velocity=0 means + // pre/postIntegrate sweep input is just the queue catch-up, + // which itself snaps in steps. Fix requires re-integrating + // PositionManager as ADDITIVE adjust_offset on top of the + // legacy chain — separate L.3 follow-up phase. // - // Airborne player remotes (rm.Airborne) and NPCs fall through to - // the legacy path below — unchanged from main per the M2 plan. - System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; + // Until that lands, stay on the legacy path (env-var unset). + // ── NEW PATH: retail-faithful per-frame remote tick ── + // (L.3.1+L.3.2 Task 3/follow-up — ACDREAM_INTERP_MANAGER=1 gates this path) + // + // Per retail's CPhysicsObj::UpdateObjectInternal (0x005156b0) + // → UpdatePositionInternal (0x00512c30) → CSequence::update + // chain (decomp investigation 2026-05-03): + // + // For a REMOTE entity (not local player), per physics tick + // the world-position advance is the sum of: + // A) animation root motion accumulated by + // update_internal (Frame::combine of crossed + // per-keyframe pos_frames deltas) OR replaced by + // InterpolationManager::adjust_offset's catch-up + // when the body is far from the queue head. + // B) body.Velocity × dt + 0.5 × accel × dt² + // (UpdatePhysicsInternal). For remotes, retail does + // NOT call apply_current_movement per tick — body. + // Velocity stays at whatever the last + // InterpolationManager type-3 ("set velocity") node + // set it to (typically zero unless the server is + // explicitly pushing velocity via VectorUpdate). + // + // So for normal grounded run/walk/strafe with no server- + // pushed velocity, ALL per-tick translation comes from (A). + // + // Acdream port mapping: + // - We don't extract per-keyframe pos_frames from the .anm + // assets. Our AnimationSequencer.CurrentVelocity is the + // synthesized equivalent (RunAnimSpeed × ForwardSpeed) + // which averages to the same effective body translation. + // - Pass it as seqVel to ComputeOffset so the + // animation-root-motion path drives body translation. + // - DO NOT call apply_current_movement per tick — that + // would set body.Velocity to RunAnimSpeed × ForwardSpeed, + // and UpdatePhysicsInternal would then add ANOTHER + // 11.7 m/s × dt on top of the seqVel motion already + // applied by ComputeOffset, producing 2× server pace + // (the user-reported "way too fast" + 1-Hz blip from + // the catch-up walking back the overshoot). + // - body.Velocity stays at 0 for grounded remotes; non- + // zero only when OnLiveVectorUpdated set it (jump + // start) — UpdatePhysicsInternal then integrates + // gravity for the airborne arc. + + System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity + ?? System.Numerics.Vector3.Zero; System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega ?? System.Numerics.Vector3.Zero; @@ -6122,20 +6133,18 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 2 (M3): queue + anim translation via PositionManager. - // ComputeOffset returns: - // - Vector3.Zero when queue is empty AND seqVel is zero - // (idle remote between UPs after head reached) — body - // stays still. - // - Direction × min(catchUpSpeed × dt, dist) when the - // queue is active and head is not reached — body chases - // the head waypoint at up to 2× motion-table max speed - // (REPLACES anim for this frame). - // - Anim root motion (seqVel × dt rotated into world) when - // the queue is empty OR head is within DesiredDistance — - // body advances with the locomotion cycle's baked - // velocity, keeping legs and body pace synchronized. - // - Blip-to-tail (tail − body) when fail_count > 3. + // Step 2: per-frame body translation. ComputeOffset returns + // either the queue catch-up (when active) or the animation + // root motion (seqVel × dt rotated to world). REPLACE + // semantics — retail's PositionManager::adjust_offset + // overwrites the offset frame with the catch-up direction, + // not adding to it. + // + // 2026-05-03 (Commit B fix for staircase regression): capture + // the pre-translation position so the collision sweep below + // (Step 4b) can resolve the full per-tick movement through + // BSP + terrain. + var preIntegratePos = rm.Body.Position; float maxSpeed = rm.Motion.GetMaxSpeed(); System.Numerics.Vector3 offset = rm.Position.ComputeOffset( dt: (double)dt, @@ -6200,18 +6209,140 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); - // Step 4b INTENTIONALLY OMITTED in M2: - // ResolveWithTransition is NOT called — the server has - // already collision-resolved the broadcast position, and - // running our sweep on tiny per-frame queue catch-up deltas - // amplifies micro-bounces into visible position blips - // (issue #40 staircase + flat-ground blips). Per retail - // spec the per-tick body advance for a remote is purely - // the queue catch-up; collision is the sender's problem. + // Step 4b (Commit B fix 2026-05-03): collision sweep — port of + // retail update_object's FUN_005148A0 Transition::FindTransitionalPosition. + // This was MISSING in the env-var path introduced by e94e791 + // (L.3.1+L.3.2 Task 3). The legacy (env-var off) path at the + // bottom of this function has it (line ~6483 "Step 4: collision + // sweep"); we just need the same call here. // - // Step 5 (landing fallback) is unreachable in this branch — - // we're gated on !rm.Airborne. Airborne player remotes fall - // through to the legacy path below where K-fix15 still fires. + // Without this: + // - Body Z drifts on slopes (visible "staircase" — horizontal + // Euler motion up a slope sinks into rising ground until + // the next UP pops it up). + // - Body slides through walls / objects between UPs. + // - Step-up / step-down doesn't engage on ledges. + // - Edge-slide doesn't engage on cliff edges. + // + // The env-var path was originally designed to mirror retail + // CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330) — a network + // packet handler entry point that does minimal work. But + // TickAnimations is the per-frame physics tick (mirrors retail + // FUN_00515020 update_object), which DOES include the collision + // sweep. Adding the sweep here makes the env-var path retail- + // faithful for the per-frame tick (matching the legacy path, + // which had it). + var postIntegratePos = rm.Body.Position; + if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) + { + // Sphere dims match local-player + legacy-path defaults + // (~0.48m radius, ~1.2m height humanoid). Step-up/down 0.4m + // matches L.2.3a retail human-scale. EdgeSlide is the retail + // default mover-flags state. + var resolveResult = _physicsEngine.ResolveWithTransition( + preIntegratePos, postIntegratePos, rm.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + // Airborne remotes must NOT pre-seed the ContactPlane — + // mirrors K-fix9 in the legacy path; otherwise + // AdjustOffset's snap-to-plane branch zeroes the +Z + // offset every step on a jump arc. + isOnGround: !rm.Airborne, + body: rm.Body, + moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + + rm.Body.Position = resolveResult.Position; + if (resolveResult.CellId != 0) + rm.CellId = resolveResult.CellId; + + // Post-resolve landing detection — mirrors K-fix15 in the + // legacy path. When the resolver says we're on ground AND + // velocity is no longer pointing up, transition back to + // grounded. Without this, gravity keeps building negative Z + // velocity until the sphere-sweep clamps each frame, but + // Airborne stays true forever. + if (rm.Airborne + && resolveResult.IsOnGround + && rm.Body.Velocity.Z <= 0f) + { + rm.Airborne = false; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.Velocity = new System.Numerics.Vector3( + rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); + rm.Motion.HitGround(); + + // Reset sequencer cycle from Falling back to whatever + // InterpretedState says. Mirrors K-fix17 in the legacy + // path. + if (ae.Sequencer is not null) + { + uint landStyle = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(landStyle, landingCmd, landingSpeed); + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + } + + // Step 5: landing fallback. The retail-faithful path leaves + // the landing transition to OnLivePositionUpdated when ACE + // sends IsGrounded=true. In practice ACE doesn't always + // broadcast that flag promptly — the body keeps falling + // under gravity and visibly disappears into the ground until + // the next non-stop UP arrives (e.g. when the player turns). + // The remote's most recent server-reported Z is an + // authoritative ground floor: if our predicted body has + // sunk below it by more than half a meter, snap up to it + // and clear airborne, mirroring the OnLivePositionUpdated + // landing-transition branch. Threshold matches retail's + // MIN_DISTANCE_TO_REACH_POSITION-style tolerance. + if (rm.Airborne + && !float.IsNaN(rm.LastServerZ) + && rm.Body.Position.Z < rm.LastServerZ - 0.5f) + { + rm.Airborne = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Interp.Clear(); + rm.Body.Position = new System.Numerics.Vector3( + rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ); + + // Swap the sequencer out of Falling — without this the + // legs stay folded in the airborne pose forever even + // though the body is now planted on the ground. Mirrors + // the legacy K-fix17 path at the bottom of TickAnimations + // (line ~6284): pick the cycle from the last-known + // InterpretedState.ForwardCommand, falling back to Ready + // when nothing is held. The next UpdateMotion the server + // sends will refine if the player was strafing/turning + // mid-jump; this just gets them out of Falling now. + if (ae.Sequencer is not null) + { + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); + } + } // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). // Track the maximum sequencer velocity magnitude seen since diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs index aea05d3c..1a6ff53c 100644 --- a/src/AcDream.Core/Physics/InterpolationManager.cs +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -7,34 +7,32 @@ namespace AcDream.Core.Physics; // ───────────────────────────────────────────────────────────────────────────── // InterpolationManager — retail CPhysicsObj interpolation queue. // -// Source spec: docs/research/2026-05-04-l3-port/04-interp-manager.md -// Retail addresses (Sept-2013 EoR PDB): -// InterpolationManager::InterpolateTo acclient @ 0x00555B20 -// InterpolationManager::adjust_offset acclient @ 0x00555D30 -// InterpolationManager::UseTime acclient @ 0x00555F20 -// InterpolationManager::NodeCompleted acclient @ 0x005559A0 -// InterpolationManager::StopInterpolating acclient @ 0x00555950 +// Ports: +// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0) +// InterpolationManager::adjust_offset (acclient @ 0x00555D30) +// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip // -// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes -// current body position + max-speed from the motion table; we return the -// world-space delta vector to apply to the body for this frame. +// FIFO position-waypoint queue (cap 20). On each physics tick the caller +// passes current body position + max-speed from the motion table; we return +// the delta vector to apply to the body for this frame. // -// Public C# API kept Vector3-based for compatibility with PositionManager and -// GameWindow callsites; retail-spec method names are documented inline. The -// retail Frame mutation pattern collapses to "return a Vector3 delta" because -// adjust_offset's offset Frame is rotation-zero (translation-only) for this -// queue's purposes — see audit 04-interp-manager.md § 4. +// Queue semantics: +// - Head = next target. Body walks toward head at catch-up speed. +// - Tail = most-recent server position. On stall we blip directly to tail +// (retail UseTime @ 0x00555F20: copies tail_ position, calls +// CPhysicsObj::SetPositionSimple, then StopInterpolating). // -// Bug fixes applied vs prior port (audit § 7): -// #1: progress_quantum accumulates dt (not step magnitude). -// #3: far-branch Enqueue sets node_fail_counter = 4 → immediate next-tick -// blip-to-tail. Triggered by distance > AutonomyBlipDistance (100 m). -// #4: secondary stall test ports the retail formula verbatim: -// cumulative_progress / progress_quantum / dt < 0.30. -// #5: tail-prune is a tail-walking loop (collapses multiple stale entries). +// Constants verified from named binary at the addresses cited above (not +// guesses): +// MAX_INTERPOLATED_VELOCITY_MOD = 2.0 +// MAX_INTERPOLATED_VELOCITY = 7.5 +// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters) +// DESIRED_DISTANCE = 0.05 // ───────────────────────────────────────────────────────────────────────────── -/// Internal queue node. type=1 = Position waypoint (only kind we use). +/// +/// Waypoint used internally by . +/// internal sealed class InterpolationNode { public Vector3 TargetPosition; @@ -43,7 +41,7 @@ internal sealed class InterpolationNode } /// -/// Per-remote-entity position interpolation queue. Caller enqueues server +/// Per-remote-entity position interpolation queue. Caller enqueues server /// position updates and calls once per physics /// tick to get the per-frame correction delta. /// @@ -51,339 +49,281 @@ public sealed class InterpolationManager { // ── public constants (retail binary values) ─────────────────────────────── - /// Maximum waypoints held before oldest (head) is dropped. + /// Maximum waypoints held before oldest is dropped. public const int QueueCap = 20; /// /// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier. - /// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122). + /// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30). /// public const float MaxInterpolatedVelocityMod = 2.0f; /// /// Fallback catch-up speed (m/s) when motion-table max speed is - /// unavailable. Retail MAX_INTERPOLATED_VELOCITY (@ 0x40f00000 line 353137). + /// unavailable (zero/tiny). + /// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30). /// public const float MaxInterpolatedVelocity = 7.5f; /// - /// Per-5-frame stall progress threshold (meters). + /// Per-5-frame stall progress threshold (meters). Body must advance at + /// least this far in frames or + /// the window counts as a stall. /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42). /// public const float MinDistanceToReachPosition = 0.20f; /// - /// Reach + duplicate-prune radius (meters). + /// Reach + duplicate-prune radius (meters). Node is popped when + /// distance to its target falls below this value; new enqueues within + /// this distance of the tail are ignored. /// Retail DESIRED_DISTANCE (@ 0x00555D30). /// public const float DesiredDistance = 0.05f; /// - /// Number of ticks per stall progress check window. + /// Number of ticks between stall progress checks. /// Retail frame_counter threshold (@ 0x00555E14). /// public const int StallCheckFrameInterval = 5; /// - /// Secondary stall ratio threshold — port verbatim from retail. - /// Audit notes the formula has odd units (1/sec); not our bug to fix. + /// Minimum fraction of cumulative progress_quantum that counts as "real + /// progress" in a stall check window. Below this fraction the window + /// counts as a stall (secondary check, applies when progress_quantum > 0). /// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73). /// public const float StallProgressMinFraction = 0.30f; /// - /// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this - /// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail > 3. + /// Stall-fail counter threshold. The body is blipped to the tail of the + /// queue when node_fail_counter EXCEEDS this value (i.e., on the + /// 4th consecutive failed window, not the 3rd). + /// Retail: node_fail_counter > 3 (@ 0x00555F39). /// public const int StallFailCountThreshold = 3; - /// - /// Distance threshold (meters) above which an Enqueue is treated as a far - /// jump and pre-arms an immediate blip. Retail outdoor value; indoor is - /// 20 m. Bug #3 fix from audit § 7. - /// - public const float AutonomyBlipDistance = 100.0f; + // ── internals ───────────────────────────────────────────────────────────── + + private readonly LinkedList _queue = new(); + + /// Frames elapsed since the last 5-frame stall-check window fired. + private int _framesSinceLastStallCheck = 0; /// - /// Sentinel for original_distance before the first window baseline is - /// taken. Retail value (@ 0x00555D30 ctor) is 999999f. + /// Cumulative sum of per-frame step magnitudes within the current + /// 5-frame window. Retail progress_quantum. /// - public const float OriginalDistanceSentinel = 999999f; + private float _progressQuantum = 0f; - private const float FEpsilon = 0.0002f; + /// + /// Distance to the head node recorded at the START of the current + /// 5-frame window. Retail original_distance. + /// + private float _distanceAtWindowStart = 0f; - // ── internals (retail field names in comments) ──────────────────────────── + /// + /// True once the first valid distance sample has been taken and + /// _distanceAtWindowStart is populated. Guards against the + /// first-window false-positive that occurs when the field defaults to 0. + /// + private bool _haveBaselineDistance = false; - private readonly LinkedList _queue = new(); // position_queue - - private int _frameCounter = 0; // frame_counter - private float _progressQuantum = 0f; // progress_quantum (sum of dt) - private float _originalDistance = OriginalDistanceSentinel; // original_distance - private int _failCount = 0; // node_fail_counter + /// + /// Number of consecutive 5-frame windows that failed both the absolute + /// and ratio progress checks. Retail node_fail_counter. + /// Blip fires when this EXCEEDS . + /// + private int _failCount = 0; // ── public API ──────────────────────────────────────────────────────────── /// True when the queue holds at least one waypoint. public bool IsActive => _queue.Count > 0; - /// Current waypoint count (visible to tests for cap verification). + /// + /// Current waypoint count (visible to the test assembly for cap verification). + /// internal int Count => _queue.Count; /// - /// Stop interpolating: drain queue and reset all stall state to sentinel - /// values. Retail StopInterpolating (@ 0x00555950). + /// Stop interpolating: clear the queue and reset all stall counters. + /// Retail StopInterpolating / destructor cleanup. /// public void Clear() { _queue.Clear(); - _frameCounter = 0; - _progressQuantum = 0f; - _originalDistance = OriginalDistanceSentinel; - _failCount = 0; + _framesSinceLastStallCheck = 0; + _progressQuantum = 0f; + _distanceAtWindowStart = 0f; + _haveBaselineDistance = false; + _failCount = 0; } /// - /// Enqueue a new server-authoritative waypoint. Implements retail - /// InterpolateTo branching: - /// - /// Already-close: if distance(body, target) ≤ - /// , queue is wiped (StopInterpolating) - /// and no node is enqueued. - /// Far: if distance(reference, target) > - /// , enqueue and set - /// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an - /// immediate blip on the next AdjustOffset call. - /// Near: tail-prune loop collapses adjacent stale entries - /// within ; cap at 20 (head eviction); - /// enqueue. - /// + /// Enqueue a new server-authoritative position waypoint. + /// + /// + /// Step 1: Duplicate-prune — if the new target is within + /// of the current tail, ignore it.
+ /// Step 2: Cap — if the queue is already at , + /// drop the oldest (head) entry.
+ /// Step 3/4: Append a new . + ///
+ /// + /// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0). ///
/// Server-reported world position. - /// Server-reported heading (radians). - /// True when body is currently following an MTP. - /// - /// Body's current world position. Used for the already-close check (versus - /// body) and as the fallback distance reference when the queue is empty. - /// Pass null if not available — far/near classification falls back - /// to "near" (no pre-armed blip). - /// - public void Enqueue( - Vector3 targetPosition, - float heading, - bool isMovingTo, - Vector3? currentBodyPosition = null) + /// Server-reported heading (radians, AC convention). + /// True when the body is in motion — gates heading validity. + public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo) { - // Retail compares dist against either the tail's stored position - // (if tail exists AND tail->type == 1) or the body's m_position. - Vector3 reference; - bool haveTail = _queue.Last is { } tail; - if (haveTail) + // Step 1: duplicate-prune + if (_queue.Last is { } last) { - reference = _queue.Last!.Value.TargetPosition; - } - else if (currentBodyPosition.HasValue) - { - reference = currentBodyPosition.Value; - } - else - { - reference = targetPosition; // dist = 0 → near branch - } - - float dist = Vector3.Distance(reference, targetPosition); - - // Far branch (retail line 352918, dist > GetAutonomyBlipDistance): - if (dist > AutonomyBlipDistance) - { - EnqueueRaw(targetPosition, heading, isMovingTo); - // Pre-arm immediate blip on next AdjustOffset (audit § 7 #3). - _failCount = StallFailCountThreshold + 1; - return; - } - - // Near & already-close branch (retail line 352962): - // distance(body, target) ≤ DesiredDistance → wipe queue, no enqueue. - if (currentBodyPosition.HasValue) - { - float bodyDist = Vector3.Distance(currentBodyPosition.Value, targetPosition); - if (bodyDist <= DesiredDistance) - { - Clear(); + if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance) return; - } } - // Near & not-close branch: - // 1. Tail-prune loop — collapse all consecutive stale tail entries - // within DesiredDistance of the new target (audit § 7 #5). - while (_queue.Last is { } stale && - Vector3.Distance(stale.Value.TargetPosition, targetPosition) <= DesiredDistance) - { - _queue.RemoveLast(); - } - - // 2. Cap at 20 — drop head (audit § 7 #6). + // Step 2: enforce cap if (_queue.Count >= QueueCap) _queue.RemoveFirst(); - // 3. Append. - EnqueueRaw(targetPosition, heading, isMovingTo); - } - - private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo) - { - _queue.AddLast(new InterpolationNode + // Steps 3+4: add node + var node = new InterpolationNode { - TargetPosition = target, + TargetPosition = targetPosition, Heading = heading, IsHeadingValid = isMovingTo, - }); + }; + _queue.AddLast(node); } /// - /// Compute the per-frame world-space correction delta. Combines the retail - /// UseTime blip-check (fail_count > 3 → snap to tail, clear queue) - /// with the per-frame adjust_offset step computation. + /// Compute the per-frame position correction delta. /// - /// Returns when: - /// • queue is empty, - /// • head reached (distance < ) — head pops, - /// • dt is invalid (≤ 0 or NaN). + /// + /// Returns when the queue is empty or when + /// the head node has been reached. Returns a snap delta (tail − + /// currentBodyPosition) after + /// consecutive stall failures (i.e., fail count EXCEEDS the threshold), + /// then clears the queue. + /// /// - /// Returns the snap delta (tail − currentBodyPosition) when fail_count - /// exceeds , then clears the queue. + /// Retail InterpolationManager::adjust_offset (@ 0x00555D30) + + /// UseTime stall/blip (@ 0x00555F20). /// /// Frame delta time (seconds). /// Current world-space body position. /// - /// Max motion-table speed for this entity's current cycle (m/s). - /// Pass 0 to use the fallback. + /// Max motion-table speed for this entity's current cycle (m/s), as + /// reported by MotionInterpreter. Pass 0 if unavailable; the fallback + /// will be used. /// + /// World-space delta to apply to the body this frame. public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp) { - // dt sanity guard — protects PhysicsBody.Position from NaN poisoning. - if (dt <= 0 || double.IsNaN(dt)) - return Vector3.Zero; + // Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position. + if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero; + // Step 1: empty queue → no correction if (_queue.First is null) return Vector3.Zero; - // Distance to head node (retail line 353083). - var head = _queue.First.Value; - float dist = Vector3.Distance(head.TargetPosition, currentBodyPosition); + // Step 2: peek head + var headNode = _queue.First.Value; - // Reach test (retail line 353089): dist ≤ DESIRED_DISTANCE → pop and - // re-baseline. NodeCompleted(1) advances to next head, also resets the - // window state. - if (dist <= DesiredDistance) + // Step 3: distance to head target + float dist = (headNode.TargetPosition - currentBodyPosition).Length(); + + // Step 4: reached node + if (dist < DesiredDistance) { - NodeCompleted(popHead: true, currentBodyPosition); + _queue.RemoveFirst(); return Vector3.Zero; } - // Catch-up speed (retail line 353122 + 353128 fallback). - float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod; - float catchUp = scaled > FEpsilon ? scaled : MaxInterpolatedVelocity; + // Step 5: compute catch-up speed + float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod; + float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity; - // Accumulate progress_quantum (audit § 7 #1: SUM OF DT, not step). - _progressQuantum += (float)dt; - _frameCounter++; + // Step 6: step magnitude (no overshoot) + float step = catchUpSpeed * (float)dt; + if (step > dist) + step = dist; - // 5-frame stall window check (retail line 353146). - if (_frameCounter >= StallCheckFrameInterval) + // Step 7: direction × step + Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step; + + // Step 8: stall detection (retail adjust_offset @ 0x00555E08-0x00555E92) + // + // Retail tracks two quantities across each 5-frame window: + // progress_quantum — cumulative sum of per-frame step magnitudes + // original_distance — distance to head at the START of the window + // + // At window end (frame_counter >= 5): + // cumulative_progress = original_distance - currentDist + // + // Primary check (@ 0x00555E42): + // cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20 m) + // → window is a stall; increment node_fail_counter. + // + // Secondary check (@ 0x00555E73, only when progress_quantum > 0): + // cumulative_progress / progress_quantum < CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30) + // → window is a stall; increment node_fail_counter. + // + // Both checks operate with sticky_object_id == 0 (we never have one). + // Either check failing counts the window as a stall. + // + // Blip fires when node_fail_counter > 3 (retail UseTime @ 0x00555F39). + // Window always resets (frame_counter=0, progress_quantum=0, + // original_distance=currentDist) after the check. + + // Initialise window baseline on first call after Clear / new motion. + if (!_haveBaselineDistance) { - float cumulative = _originalDistance - dist; + _distanceAtWindowStart = dist; + _haveBaselineDistance = true; + } - // Primary check (retail line 353150-353166): - // cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20) - bool primaryPass = cumulative >= MinDistanceToReachPosition; + _progressQuantum += step; + _framesSinceLastStallCheck++; - // Secondary check (retail line 353169-353172, audit § 7 #4): - // cumulative > F_EPSILON - // AND (cumulative / progress_quantum / dt) >= 0.30 - // - // Port verbatim despite weird units; audit notes this may be a - // Turbine bug or x87-stack misread by Binary Ninja. Mirroring bytes. - bool secondaryPass = false; - if (cumulative > FEpsilon && _progressQuantum > 0f && dt > 0) - { - float ratio = (cumulative / _progressQuantum) / (float)dt; - secondaryPass = ratio >= StallProgressMinFraction; - } + if (_framesSinceLastStallCheck >= StallCheckFrameInterval) + { + float cumulativeProgress = _distanceAtWindowStart - dist; - if (!primaryPass && !secondaryPass) + bool primaryFail = cumulativeProgress < MinDistanceToReachPosition; + bool secondaryFail = _progressQuantum > 0f && + (cumulativeProgress / _progressQuantum) < StallProgressMinFraction; + + if (primaryFail || secondaryFail) { _failCount++; + // Blip-to-tail: retail UseTime (@ 0x00555F20) reads + // position_queue.tail_, copies its position to a local, + // calls CPhysicsObj::SetPositionSimple, then + // StopInterpolating. Snap target is the TAIL (the most + // recent server position), not the head. + if (_failCount > StallFailCountThreshold) + { + Vector3 tailPos = _queue.Last!.Value.TargetPosition; + Clear(); + return tailPos - currentBodyPosition; + } } else { _failCount = 0; } - // Re-baseline window regardless of pass/fail. - _frameCounter = 0; - _progressQuantum = 0f; - _originalDistance = dist; - } - else if (_originalDistance >= OriginalDistanceSentinel - 0.5f) - { - // First call after Clear / new motion: seed the baseline so the - // first 5-frame window's cumulative is computed against frame-0 - // distance, not the 999999f sentinel. Retail handles this via - // the sentinel itself — the sentinel produces a huge cumulative - // that always passes — but we use a baseline-seeded approach so - // the secondary check has sane progress_quantum behavior. - _originalDistance = dist; + // Reset the 5-frame window regardless of pass/fail. + _framesSinceLastStallCheck = 0; + _progressQuantum = 0f; + _distanceAtWindowStart = dist; } - // Retail UseTime blip check (@ 0x00555F39): fail_count > 3 → snap to - // tail, clear queue. Placed AFTER the stall window logic so it fires - // in the same tick as both: - // (a) the just-incremented fail_count from a stall window pass, AND - // (b) a far-branch Enqueue pre-arm (fail_count = 4 set externally). - // Retail splits this into a separate UseTime call; we collapse it. - if (_failCount > StallFailCountThreshold) - { - Vector3 tailPos = _queue.Last!.Value.TargetPosition; - Clear(); - return tailPos - currentBodyPosition; - } - - // Per-frame step magnitude (retail line 353218). - float step = catchUp * (float)dt; - // No-overshoot scaling (retail line 353231): if step would overshoot - // dist, clamp to dist. - if (step > dist) - step = dist; - - // Direction × step. - Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step; + // Step 9: return per-frame delta return delta; } - - /// - /// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached; - /// popHead=false during stall fail (re-baseline only). For our collapsed - /// architecture we always re-baseline on pop. - /// - private void NodeCompleted(bool popHead, Vector3 currentBodyPosition) - { - _frameCounter = 0; - _progressQuantum = 0f; - - if (popHead && _queue.First != null) - { - _queue.RemoveFirst(); - } - - // Re-baseline on the new head, or reset to sentinel if queue empty. - if (_queue.First is { } newHead) - { - _originalDistance = Vector3.Distance(newHead.Value.TargetPosition, currentBodyPosition); - } - else - { - _originalDistance = OriginalDistanceSentinel; - } - } } diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs index 721ddbc7..fd239315 100644 --- a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs @@ -360,52 +360,6 @@ public sealed class InterpolationManagerTests "First stall window must NOT trigger a blip (would require > 3 consecutive failures)."); } - // ========================================================================= - // Far-branch enqueue: when the new target is beyond AutonomyBlipDistance - // (100 m outdoor) of the reference (tail or body), retail - // InterpolationManager::InterpolateTo (acclient @ 0x00555B20 line 352944) - // sets node_fail_counter = 4 so the very next stall-check blips to the - // tail. Audit 04-interp-manager.md § 7 gap #3. - // - // Effect: the body teleports to the freshly-enqueued tail on the first - // adjust_offset call after a far enqueue, instead of drifting toward it - // at catch-up speed. Critical for >100 m server-side teleports / cell - // crossings on observed remotes. - // ========================================================================= - - [Fact] - public void Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset() - { - var mgr = Make(); - // Target > AutonomyBlipDistance (100 m) from origin → far branch. - var farTarget = new Vector3(150f, 0f, 0f); - - mgr.Enqueue(farTarget, heading: 0f, isMovingTo: true, currentBodyPosition: BodyOrigin); - - // Single AdjustOffset call: body still at origin, queue has 1 node, - // node_fail_counter = 4 (set by far-branch enqueue) > 3 threshold, - // so the very first stall-check fires a blip to the tail. - // - // The blip delta should be the full far distance (≈150 m), not a - // single per-frame catch-up step. - Vector3? blipDelta = null; - for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++) - { - var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f); - // Blip fires when delta >> per-frame step. Per-frame step at - // 4 m/s × 2 (mod) × 0.016 s = 0.128 m. Blip is 150 m. - if (delta.Length() > 50f) - { - blipDelta = delta; - break; - } - } - - Assert.NotNull(blipDelta); - Assert.Equal(150f, blipDelta!.Value.X, precision: 4); - Assert.False(mgr.IsActive, "Queue must be cleared after blip."); - } - [Fact] public void AdjustOffset_DtZeroOrNegative_ReturnsZero() { diff --git a/tools/InspectCoatTex/Program.cs b/tools/InspectCoatTex/Program.cs deleted file mode 100644 index 58fdaabe..00000000 --- a/tools/InspectCoatTex/Program.cs +++ /dev/null @@ -1,313 +0,0 @@ -// InspectCoatTex — Issue #37 diagnostic. -// Inspect the byte-level contents of the SurfaceTextures the server sends in -// TextureChanges for the player's Academy Coat (part 9 / Aluvian Male): -// new: 0x05001AFE, 0x05001AFC -// old: 0x050003D5, 0x050003D4 -// And dump every Surface (0x08) under the part 9 GfxObj 0x0100120D, plus the -// DefaultPaletteId of each new texture (and the first 32 entries of that -// palette to see if it's a SKIN or COAT palette). -using System; -using System.IO; -using AcDream.Core.Textures; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Options; -using SysEnv = System.Environment; - -string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); -Console.WriteLine($"datDir = {datDir}"); -using var dats = new DatCollection(datDir, DatAccessType.Read); - -uint[] surfaceTextureIds = { 0x05001AFEu, 0x05001AFCu, 0x050003D5u, 0x050003D4u }; -uint part9GfxObj = 0x0100120Du; - -Console.WriteLine(); -Console.WriteLine("==================== SurfaceTexture (0x05) wrappers ===================="); -foreach (var stid in surfaceTextureIds) - DumpSurfaceTexture(dats, stid); - -Console.WriteLine(); -Console.WriteLine("==================== Part 9 GfxObj 0x0100120D Surfaces (0x08) ===================="); -DumpGfxObjSurfaces(dats, part9GfxObj); - -Console.WriteLine(); -Console.WriteLine("==================== Cross-check: which Surfaces reference the OLD textures? ===================="); -DumpReverseLookup(dats, part9GfxObj, new[] { 0x050003D5u, 0x050003D4u }); - -Console.WriteLine(); -Console.WriteLine("==================== Per-polygon flags for gfx 0x0100120D (testing Agent 4 hypotheses) ===================="); -DumpPolygons(dats, part9GfxObj); - -Console.WriteLine(); -Console.WriteLine("==================== Academy Coat ClothingBase + its SubPalettes (testing compact-vs-full-palette hypothesis) ===================="); -DumpClothingBase(dats, 0x10000ABFu); - -return 0; - -// ----------------------------------------------------------------------------- -static void DumpSurfaceTexture(DatCollection dats, uint stid) -{ - Console.WriteLine(); - Console.WriteLine($"--- SurfaceTexture 0x{stid:X8} ---"); - if (!dats.TryGet(stid, out var st) || st is null) - { - Console.WriteLine(" NOT FOUND as SurfaceTexture."); - return; - } - Console.WriteLine($" Textures.Count = {st.Textures.Count}"); - if (st.Textures.Count == 0) return; - - uint rsid = (uint)st.Textures[0]; - Console.WriteLine($" First inner Texture id = 0x{rsid:X8}"); - if (!dats.TryGet(rsid, out var rs) || rs is null) - { - Console.WriteLine(" Inner not found as RenderSurface."); - return; - } - Console.WriteLine($" Format = {rs.Format} (0x{(uint)rs.Format:X8})"); - Console.WriteLine($" Dimensions = {rs.Width} x {rs.Height}"); - Console.WriteLine($" SourceData bytes = {rs.SourceData?.Length ?? 0}"); - Console.WriteLine($" DefaultPaletteId = 0x{rs.DefaultPaletteId:X8}"); - - Palette? palette = null; - if (rs.DefaultPaletteId != 0 - && dats.TryGet(rs.DefaultPaletteId, out var pal) - && pal is not null) - { - palette = pal; - Console.WriteLine($" Palette.Colors.Count = {pal.Colors.Count}"); - Console.WriteLine($" Palette[0..31]:"); - int show = Math.Min(32, pal.Colors.Count); - for (int i = 0; i < show; i++) - { - var c = pal.Colors[i]; - byte a = c.Alpha, r = c.Red, g = c.Green, b = c.Blue; - string desc = ClassifyColor(r, g, b); - Console.WriteLine($" [{i:D2}] A={a:X2} R={r:X2} G={g:X2} B={b:X2} {desc}"); - } - } - else if (rs.DefaultPaletteId != 0) - { - Console.WriteLine(" DefaultPalette not found in dats."); - } - - var dec = SurfaceDecoder.DecodeRenderSurface(rs, palette); - if (dec.Rgba8 is null || dec.Rgba8.Length == 0) - { - Console.WriteLine(" Decode FAILED."); - return; - } - long sumR = 0, sumG = 0, sumB = 0, sumA = 0; - int n = dec.Width * dec.Height; - for (int i = 0; i < n; i++) - { - sumR += dec.Rgba8[i * 4 + 0]; - sumG += dec.Rgba8[i * 4 + 1]; - sumB += dec.Rgba8[i * 4 + 2]; - sumA += dec.Rgba8[i * 4 + 3]; - } - byte mr = (byte)(sumR / n), mg = (byte)(sumG / n), mb = (byte)(sumB / n), ma = (byte)(sumA / n); - Console.WriteLine($" Decoded mean RGBA = R={mr:X2} G={mg:X2} B={mb:X2} A={ma:X2} {ClassifyColor(mr, mg, mb)}"); -} - -static void DumpGfxObjSurfaces(DatCollection dats, uint gfxId) -{ - Console.WriteLine(); - Console.WriteLine($"--- GfxObj 0x{gfxId:X8} ---"); - if (!dats.TryGet(gfxId, out var gfx) || gfx is null) - { - Console.WriteLine(" NOT FOUND."); - return; - } - Console.WriteLine($" Surfaces.Count = {gfx.Surfaces.Count}"); - foreach (var sQid in gfx.Surfaces) - { - uint sid = (uint)sQid; - if (!dats.TryGet(sid, out var surf) || surf is null) - { - Console.WriteLine($" Surface 0x{sid:X8}: NOT FOUND"); - continue; - } - Console.WriteLine($" Surface 0x{sid:X8}"); - Console.WriteLine($" Type = {surf.Type}"); - Console.WriteLine($" OrigTextureId = 0x{surf.OrigTextureId:X8}"); - Console.WriteLine($" OrigPaletteId = 0x{surf.OrigPaletteId:X8}"); - Console.WriteLine($" ColorValue = 0x{surf.ColorValue:X8}"); - Console.WriteLine($" Translucency = {surf.Translucency}"); - Console.WriteLine($" Luminosity = {surf.Luminosity}"); - Console.WriteLine($" Diffuse = {surf.Diffuse}"); - - // What does that OrigTextureId actually wrap? - uint origTex = (uint)surf.OrigTextureId; - if (origTex != 0 && dats.TryGet(origTex, out var st) && st is not null && st.Textures.Count > 0) - { - uint rsid = (uint)st.Textures[0]; - if (dats.TryGet(rsid, out var rs) && rs is not null) - { - Console.WriteLine($" -> wraps RS 0x{rsid:X8}: {rs.Format} {rs.Width}x{rs.Height} defaultPal=0x{rs.DefaultPaletteId:X8}"); - } - } - } -} - -static void DumpReverseLookup(DatCollection dats, uint gfxId, uint[] oldTextures) -{ - if (!dats.TryGet(gfxId, out var gfx) || gfx is null) return; - foreach (var sQid in gfx.Surfaces) - { - uint sid = (uint)sQid; - if (!dats.TryGet(sid, out var surf) || surf is null) continue; - uint origTex = (uint)surf.OrigTextureId; - foreach (var oldT in oldTextures) - { - if (origTex == oldT) - Console.WriteLine($" Surface 0x{sid:X8} OrigTextureId=0x{origTex:X8} matches OLD 0x{oldT:X8} (server replaces this)"); - } - } -} - -static void DumpClothingBase(DatCollection dats, uint cbId) -{ - Console.WriteLine(); - Console.WriteLine($"--- ClothingTable 0x{cbId:X8} ---"); - if (!dats.TryGet(cbId, out var cb) || cb is null) - { - Console.WriteLine(" NOT FOUND."); - return; - } - Console.WriteLine($" ClothingBaseEffects.Count = {cb.ClothingBaseEffects.Count}"); - Console.WriteLine($" ClothingSubPalEffects.Count = {cb.ClothingSubPalEffects.Count}"); - - // For each SubPalEffect (PaletteTemplate variant), dump its CloSubPalettes - var palettesProbed = new System.Collections.Generic.HashSet(); - int subPalEffectIdx = 0; - foreach (var (paletteTemplate, subPalEffect) in cb.ClothingSubPalEffects) - { - if (subPalEffectIdx++ >= 3) break; // first 3 only - Console.WriteLine(); - Console.WriteLine($" ClothingSubPalEffect[paletteTemplate=0x{(uint)paletteTemplate:X8}]:"); - Console.WriteLine($" Icon = 0x{(uint)subPalEffect.Icon:X8}"); - Console.WriteLine($" CloSubPalettes.Count = {subPalEffect.CloSubPalettes.Count}"); - foreach (var sp in subPalEffect.CloSubPalettes) - { - uint subPalId = (uint)sp.PaletteSet; - Console.WriteLine($" CloSubPalette PaletteSet=0x{subPalId:X8}, Ranges.Count={sp.Ranges.Count}"); - foreach (var r in sp.Ranges) - { - Console.WriteLine($" Range Offset={r.Offset} NumColors={r.NumColors} (raw palette indices)"); - } - - // Probe whatever this id resolves to. AC has both Palette (0x04) and - // PaletteSet (0x0F) types; without strong typing we just probe both. - if (palettesProbed.Add(subPalId)) - { - if (dats.TryGet(subPalId, out var palDirect) && palDirect is not null) - { - Console.WriteLine($" -> Palette 0x{subPalId:X8}: Colors.Count={palDirect.Colors.Count}"); - if (palDirect.Colors.Count > 0) - { - var c = palDirect.Colors[0]; - Console.WriteLine($" [00] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - if (palDirect.Colors.Count > 24) - { - var c = palDirect.Colors[24]; - Console.WriteLine($" [24] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - if (palDirect.Colors.Count > 100) - { - var c = palDirect.Colors[100]; - Console.WriteLine($" [100] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - } - else - { - Console.WriteLine($" -> NOT a direct Palette (might be PaletteSet 0x0F — AC has dat type for set-of-palettes)"); - } - } - } - } -} - -static void DumpPolygons(DatCollection dats, uint gfxId) -{ - Console.WriteLine(); - Console.WriteLine($"--- Polygons of GfxObj 0x{gfxId:X8} ---"); - if (!dats.TryGet(gfxId, out var gfx) || gfx is null) - { - Console.WriteLine(" NOT FOUND."); - return; - } - Console.WriteLine($" Polygon count = {gfx.Polygons.Count}"); - Console.WriteLine($" Surfaces[0..N] (for cross-ref):"); - for (int i = 0; i < gfx.Surfaces.Count; i++) - { - uint sid = (uint)gfx.Surfaces[i]; - if (dats.TryGet(sid, out var surf) && surf is not null) - Console.WriteLine($" [{i}] surfId=0x{sid:X8} Type={surf.Type}"); - } - Console.WriteLine(); - Console.WriteLine($" {"PolyKey",6} {"SidesType",-12} {"Stippling",-32} {"NumVerts",8} {"PosSurf",7} {"NegSurf",7} {"PosUVs",6} {"NegUVs",6}"); - int idx = 0; - int stDouble = 0, stBoth = 0, stSingle = 0; - int nonImageSurf = 0; - var sidesTypeHistogram = new System.Collections.Generic.Dictionary(); - foreach (var (key, p) in gfx.Polygons) - { - // Count first - int sti = (int)p.SidesType; - if (!sidesTypeHistogram.ContainsKey(sti)) sidesTypeHistogram[sti] = 0; - sidesTypeHistogram[sti]++; - if (sti == 0) stSingle++; - else if (sti == 1) stDouble++; - else if (sti == 2) stBoth++; - - // Check Type & 6 gate - if (p.PosSurface >= 0 && p.PosSurface < gfx.Surfaces.Count) - { - uint sid = (uint)gfx.Surfaces[p.PosSurface]; - if (dats.TryGet(sid, out var surf) && surf is not null) - { - uint typeBits = (uint)surf.Type; - bool hasImage = (typeBits & 0x02) != 0; - bool hasClipMap = (typeBits & 0x04) != 0; - if (!hasImage && !hasClipMap) nonImageSurf++; - } - } - - if (idx < 30 || sti != 0) // first 30 + all unusual SidesType - { - Console.WriteLine($" {key,6} {p.SidesType,-12} {p.Stippling,-32} {p.VertexIds.Count,8} {p.PosSurface,7} {p.NegSurface,7} {p.PosUVIndices.Count,6} {p.NegUVIndices.Count,6}"); - } - idx++; - } - Console.WriteLine(); - Console.WriteLine($" SidesType histogram: {string.Join(", ", sidesTypeHistogram.Select(kv => $"{kv.Key}={kv.Value}"))}"); - Console.WriteLine($" SidesType=0 (ST_SINGLE) = {stSingle}"); - Console.WriteLine($" SidesType=1 (ST_DOUBLE / None) = {stDouble} <-- if >0, retail draws BOTH faces; acdream draws ONE"); - Console.WriteLine($" SidesType=2 (ST_BOTH / Clockwise) = {stBoth}"); - Console.WriteLine($" Polygons with surface lacking Base1Image+Base1ClipMap: {nonImageSurf}"); - Console.WriteLine($" (retail SKIPS these polygons in DrawPolyInternal; acdream renders them with surface.ColorValue)"); -} - -// Heuristic name for what RGB looks like: skin tones (peach/tan), coat tones -// (blue/purple/red), neutral grey. Cheap classifier so we can eyeball palette -// dumps without staring at raw hex. -static string ClassifyColor(byte r, byte g, byte b) -{ - int max = Math.Max(r, Math.Max(g, b)); - int min = Math.Min(r, Math.Min(g, b)); - int range = max - min; - if (max < 16) return "(near-black)"; - if (range < 12) return "(grey)"; - // Skin-tone heuristic: R > G > B, R-G in ~5..40, G-B in ~5..40, all >= 60. - if (r > g && g > b && (r - g) >= 4 && (g - b) >= 2 && r >= 60 && g >= 40) - return "(skin-tone-ish)"; - if (b > r && b > g) return "(blue-ish)"; - if (r > g && r > b) return "(red-ish)"; - if (g > r && g > b) return "(green-ish)"; - return ""; -}