/investigate deliverable for the inbound (remote-entity) animation+position
retail-parity effort. 10 deviations (DEV-1..10) mapped and adversarially
verified against the named retail decomp + ACE port + current code (9
confirmed, 1 refuted-and-corrected).
Headline: the #39-era UP-pace->cycle inference layer's premise ('wire goes
silent on Shift toggle') is refuted at both oracles — retail sends a fresh
MoveToState on HoldRun toggle while moving (0x006b37a8) and ACE rebroadcasts
every MoveToState unconditionally (GameActionMoveToState.cs:36); retail has
NO pace->animation adaptation anywhere (position error is absorbed solely by
the InterpolationManager chase, already ported verbatim in L.3).
Registers sub-lane L.2g in the roadmap: port the CMotionInterp inbound funnel
verbatim for all remote entity classes, slices S0-S6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
610 lines
51 KiB
Markdown
610 lines
51 KiB
Markdown
# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
|
|
|
|
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics`
|
|
|
|
Key files (all confirmed to exist):
|
|
- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`)
|
|
- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`)
|
|
- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic)
|
|
- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation
|
|
- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager`
|
|
- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending)
|
|
- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal`
|
|
- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`)
|
|
- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick
|
|
- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs`
|
|
|
|
IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
|
|
1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
|
|
2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`).
|
|
|
|
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
|
|
|
|
---
|
|
|
|
## Q1 — INBOUND ENTRY: wire message → motion interpreter
|
|
|
|
### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp
|
|
|
|
1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20).
|
|
2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer.
|
|
3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).
|
|
|
|
4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`:
|
|
- Guards on `FastTick` (line 178).
|
|
- Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum).
|
|
- Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`:
|
|
- `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply)
|
|
- `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls)
|
|
|
|
5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path:
|
|
```csharp
|
|
var minterp = PhysicsObj.get_minterp();
|
|
minterp.RawState.SetState(moveToState.RawMotionState);
|
|
if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; }
|
|
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
|
|
minterp.apply_raw_movement(true, allowJump);
|
|
```
|
|
This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.**
|
|
|
|
6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
|
|
|
|
### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site)
|
|
|
|
`PhysicsObj.cs:340-346`:
|
|
```csharp
|
|
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
|
|
{
|
|
LastMoveWasAutonomous = true;
|
|
if (MovementManager == null) return WeenieError.NoAnimationTable;
|
|
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
|
|
return MovementManager.PerformMovement(mvs);
|
|
}
|
|
```
|
|
→ `MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`).
|
|
|
|
`MotionInterp.DoMotion` (line 112):
|
|
- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119).
|
|
- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).
|
|
- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`.
|
|
- Action-count gating (line 147-151): `GetNumActions() >= 6` → `TooManyActions`.
|
|
- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below).
|
|
- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState).
|
|
|
|
### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
|
|
|
|
`Animation/MotionInterp.cs:51-110`:
|
|
1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne.
|
|
2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate).
|
|
3. Otherwise (line 64-90):
|
|
- `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67).
|
|
- `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion` → `GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4).
|
|
- On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3).
|
|
- If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state.
|
|
4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100).
|
|
5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
|
|
|
|
`contact_allows_move` (`MotionInterp.cs:584-602`):
|
|
```
|
|
Dead / Falling / TurnRight..TurnLeft range → always true
|
|
non-creature WeenieObj → always true
|
|
!PhysicsState.Gravity → true
|
|
!TransientState.Contact → false // airborne, no ground contact
|
|
TransientState.OnWalkable → true // standing on walkable floor
|
|
else → false
|
|
```
|
|
|
|
---
|
|
|
|
## Q2 — TRANSITION: walk↔run while already moving (no full stop)
|
|
|
|
### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller)
|
|
|
|
`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`):
|
|
```csharp
|
|
InterpretedState.CurrentStyle = RawState.CurrentStyle;
|
|
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
|
|
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
|
|
InterpretedState.SideStepCommand = RawState.SideStepCommand;
|
|
...
|
|
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
|
|
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
|
|
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
|
|
apply_interpreted_movement(cancelMoveTo, allowJump);
|
|
```
|
|
|
|
`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**:
|
|
```csharp
|
|
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
|
|
{
|
|
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
|
|
switch (motion)
|
|
{
|
|
case RunForward: return; // already run, no-op
|
|
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
|
|
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
|
|
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
|
|
}
|
|
if (motion == SideStepRight)
|
|
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
|
|
|
|
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
|
|
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
|
|
}
|
|
```
|
|
`apply_run_to_command` (`MotionInterp.cs:525-562`):
|
|
```csharp
|
|
case WalkForward:
|
|
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id → RunForward substate id
|
|
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
|
|
break;
|
|
case TurnRight: speed *= RunTurnFactor; break; // 1.5
|
|
case SideStepRight:
|
|
speed *= speedMod;
|
|
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
|
|
speed = Math.Sign(speed) * MaxSidestepAnimRate;
|
|
break;
|
|
```
|
|
**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap.
|
|
|
|
### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand
|
|
|
|
`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`):
|
|
```csharp
|
|
if (InterpretedState.ForwardCommand == RunForward)
|
|
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
|
|
|
|
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
|
|
|
|
if (contact_allows_move(InterpretedState.ForwardCommand))
|
|
{
|
|
if (!StandingLongJump)
|
|
{
|
|
movementParams.Speed = InterpretedState.ForwardSpeed;
|
|
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
|
|
...sidestep, turn similarly...
|
|
```
|
|
|
|
### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps
|
|
|
|
`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt:
|
|
|
|
```csharp
|
|
if ((motion & CommandMask.SubState) != 0)
|
|
{
|
|
var motionID = motion & 0xFFFFFF;
|
|
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
|
|
...
|
|
if (is_allowed(motion, motionData, currState))
|
|
{
|
|
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
|
|
{
|
|
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
|
|
// since WalkForward != RunForward numerically)
|
|
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
|
|
subtract_motion(sequence, motionData, currState.SubstateMod);
|
|
combine_motion(sequence, motionData, speedMod);
|
|
currState.SubstateMod = speedMod;
|
|
return true;
|
|
}
|
|
|
|
// GENERAL PATH — actually taken for Walk->Run:
|
|
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
|
|
|
|
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
|
|
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
|
|
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
|
|
|
|
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
|
|
{
|
|
// fallback: go through the style's default substate as an intermediate hop
|
|
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
|
|
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
|
|
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
|
|
}
|
|
|
|
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
|
|
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
|
|
|
|
if (motionData_ != null)
|
|
{
|
|
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
|
|
add_motion(sequence, motionData_, speedMod); // append default->run link anim
|
|
}
|
|
else
|
|
{
|
|
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
|
|
}
|
|
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
|
|
|
|
currState.SubstateMod = speedMod;
|
|
currState.Substate = motion; // RunForward is now the tracked substate
|
|
re_modify(sequence, currState); // re-apply any active modifiers on top
|
|
|
|
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation** — `get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
|
|
|
|
One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
|
|
|
|
---
|
|
|
|
## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone`
|
|
|
|
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
|
|
|
|
### Layer 1: `MotionInterp.PendingMotions` (`List<MotionNode>`, `MotionInterp.cs:24`)
|
|
|
|
- **Type**: `LinkedList<MotionNode>`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError).
|
|
- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`):
|
|
```csharp
|
|
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
|
|
PhysicsObj.IsAnimating = true;
|
|
```
|
|
Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495).
|
|
- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`):
|
|
```csharp
|
|
var motionData = PendingMotions.First;
|
|
if (motionData != null)
|
|
{
|
|
var pendingMotion = motionData.Value;
|
|
if ((pendingMotion.Motion & CommandMask.Action) != 0)
|
|
{
|
|
PhysicsObj.unstick_from_object();
|
|
InterpretedState.RemoveAction();
|
|
RawState.RemoveAction();
|
|
}
|
|
motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy)
|
|
if (motionData != null)
|
|
{
|
|
PendingMotions.Remove(motionData);
|
|
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
|
|
}
|
|
}
|
|
```
|
|
This is called ONLY from `PhysicsObj.MotionDone(motion, success)` → `MovementManager.MotionDone(motion, success)` → `MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id).
|
|
- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`.
|
|
- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf.
|
|
- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
|
|
|
|
### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`, `Managers/MotionTableManager.cs:13`)
|
|
|
|
- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done.
|
|
- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`.
|
|
- Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
|
|
- **Popped/completed by TWO different drivers**:
|
|
1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
|
|
2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`).
|
|
- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires:
|
|
```csharp
|
|
if (HookObj != null)
|
|
{
|
|
var node = AnimList.First;
|
|
if (!node.Equals(FirstCyclic))
|
|
HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
|
|
}
|
|
```
|
|
i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side.
|
|
|
|
### Summary of the 3-tier queue relationship
|
|
```
|
|
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
|
|
↑ pushed onto tail by add_motion() during GetObjectSequence
|
|
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
|
|
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
|
|
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
|
|
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
|
|
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
|
|
```
|
|
|
|
---
|
|
|
|
## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
|
|
|
|
Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.**
|
|
|
|
### `Sequence.append_animation` (`Sequence.cs:203-216`)
|
|
```csharp
|
|
public void append_animation(AnimData animData)
|
|
{
|
|
var node = new AnimSequenceNode(animData);
|
|
if (!node.has_anim()) return;
|
|
AnimList.AddLast(node);
|
|
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
|
|
if (CurrAnim == null)
|
|
{
|
|
CurrAnim = AnimList.First;
|
|
FrameNumber = CurrAnim.Value.get_starting_frame();
|
|
}
|
|
}
|
|
```
|
|
Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`.
|
|
|
|
### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended
|
|
```csharp
|
|
public void remove_cyclic_anims()
|
|
{
|
|
var node = FirstCyclic;
|
|
while (node != null)
|
|
{
|
|
if (CurrAnim.Equals(node))
|
|
{
|
|
CurrAnim = node.Previous;
|
|
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
|
|
else FrameNumber = 0.0f;
|
|
}
|
|
var next = node.Next;
|
|
AnimList.Remove(node.Value);
|
|
node = next;
|
|
}
|
|
FirstCyclic = AnimList.Last;
|
|
}
|
|
```
|
|
This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch).
|
|
|
|
### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk
|
|
Forward-playback branch (`timeElapsed >= 0.0f`):
|
|
```csharp
|
|
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
|
|
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
|
|
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
|
|
currAnim = animNode.Value;
|
|
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame — never carries phase
|
|
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
|
|
```
|
|
So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code.
|
|
|
|
### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes
|
|
```csharp
|
|
public void apricot()
|
|
{
|
|
var node = AnimList.First;
|
|
while (!node.Equals(CurrAnim))
|
|
{
|
|
if (node.Equals(FirstCyclic)) break;
|
|
AnimList.Remove(node);
|
|
node = AnimList.First;
|
|
}
|
|
}
|
|
```
|
|
Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
|
|
|
|
---
|
|
|
|
## Q5 — POSITION DRIVE between inbound packets: what advances position?
|
|
|
|
**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`):
|
|
|
|
```csharp
|
|
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
|
|
{
|
|
var offsetFrame = new AFrame();
|
|
if (!State.HasFlag(PhysicsState.Hidden))
|
|
{
|
|
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
|
|
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
|
|
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
|
|
else
|
|
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
|
|
}
|
|
if (PositionManager != null)
|
|
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
|
|
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
|
|
...
|
|
}
|
|
```
|
|
|
|
**Step 1 — `PartArray.Update` → `Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`):
|
|
```csharp
|
|
public void Update(float quantum, ref AFrame offsetFrame)
|
|
{
|
|
if (AnimList.First != null)
|
|
{
|
|
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
|
|
apricot();
|
|
}
|
|
else if (offsetFrame != null)
|
|
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
|
|
}
|
|
```
|
|
Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick:
|
|
```csharp
|
|
if (currAnim.Anim.PosFrames != null)
|
|
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
|
|
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
|
|
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
|
|
```
|
|
So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied.
|
|
|
|
**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable` — **while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.**
|
|
|
|
**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
|
|
|
|
---
|
|
|
|
## Q6 — CORRECTION: how do inbound position updates fix accumulated error?
|
|
|
|
Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`):
|
|
```csharp
|
|
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
|
|
{
|
|
... staleness/sequence-number check against UpdateTimes[4] ...
|
|
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
|
|
{
|
|
teleport_hook(true);
|
|
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
if (!contact) return false;
|
|
if (PlayerDistance < 96.0f)
|
|
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
|
|
else
|
|
{
|
|
PositionManager?.StopInterpolating();
|
|
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
```
|
|
`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
|
|
|
|
`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`.
|
|
|
|
### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`)
|
|
```csharp
|
|
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
|
|
var dist = dest.Distance(position);
|
|
|
|
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
|
|
{
|
|
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
|
|
{
|
|
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
|
|
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
|
|
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
|
|
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
|
|
}
|
|
else
|
|
{
|
|
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
|
|
StopInterpolating(); // close enough already — snap heading only, clear queue
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
|
|
enqueue new PositionType node; NodeFailCounter = 4;
|
|
}
|
|
```
|
|
|
|
### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick
|
|
|
|
```csharp
|
|
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
|
|
var first = queue.First.Value;
|
|
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
|
|
|
|
var dist = PhysicsObj.Position.Distance(first.Position);
|
|
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
|
|
|
|
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
|
|
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
|
|
|
|
var delta = OriginalDistance - dist;
|
|
ProgressQuantum += quantum; FrameCounter++;
|
|
|
|
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
|
|
{
|
|
// "making good enough progress" — keep smoothing
|
|
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
|
|
var offset = first.Position.Subtract(PhysicsObj.Position);
|
|
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
|
|
var distance = offset.Origin.Length();
|
|
if (distance <= 0.05f) NodeCompleted(true);
|
|
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
|
|
if (KeepHeading) offset.set_heading(0.0f);
|
|
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal — added on top of animation displacement
|
|
return;
|
|
}
|
|
NodeFailCounter++;
|
|
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
|
|
```
|
|
|
|
Constants (verbatim, `InterpolationManager.cs:18-19`):
|
|
```
|
|
LargeDistance = 999999.0f
|
|
MaxInterpolatedVelocity = 7.5f
|
|
UseAdjustedSpeed = true (static, defaults on)
|
|
```
|
|
Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon.
|
|
|
|
Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
|
|
|
|
`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
|
|
|
|
**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
|
|
|
|
---
|
|
|
|
## Q7 — STOP: motion → Ready/stand
|
|
|
|
### Two distinct stop mechanisms in `MotionInterp`
|
|
|
|
**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
|
|
```csharp
|
|
if (contact_allows_move(motion))
|
|
{
|
|
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
|
|
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
|
|
else
|
|
{
|
|
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
|
|
if (result == WeenieError.None)
|
|
{
|
|
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
|
|
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
|
|
}
|
|
}
|
|
}
|
|
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
|
|
|
|
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
|
|
```
|
|
`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`):
|
|
```csharp
|
|
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
|
|
add_to_queue((uint)MotionCommand.Ready, counter, seq);
|
|
```
|
|
`MotionTable.StopObjectMotion` → `StopSequenceMotion` (`MotionTable.cs:315-356`):
|
|
```csharp
|
|
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
|
|
{
|
|
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
|
|
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
|
|
return true;
|
|
}
|
|
if ((motion & CommandMask.Modifier) == 0) return false;
|
|
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
|
|
```
|
|
**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose.
|
|
|
|
**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle:
|
|
```csharp
|
|
PhysicsObj.cancel_moveto();
|
|
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
|
|
|
|
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
|
|
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
|
|
|
|
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
|
|
add_to_queue(0, (uint)MotionCommand.Ready, jump);
|
|
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
|
|
```
|
|
`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`):
|
|
```csharp
|
|
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
|
|
while (currState.Modifiers.First != null)
|
|
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
|
|
// then stop the current Substate (Walk/Run/etc.) itself
|
|
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
|
|
```
|
|
|
|
### Velocity zeroing
|
|
`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
|
|
|
|
### Residual-slide prevention
|
|
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp:
|
|
```csharp
|
|
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
|
|
```
|
|
`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
|
|
|
|
### `RemoveLinkAnimations` on cell-exit
|
|
Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996` → `PartArray.HandleEnterWorld` → `MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3).
|