feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 / LeaveGround 0x00528b00 verbatim (creature+gravity gates, the RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via GetLeaveGroundVelocity with the autonomous flag, jump-state resets, apply_current_movement re-sync); enter_default_state 0x00528c80 per A8 (fresh states, InitializeMotionTables seam, sentinel APPENDED without draining pending_motions — pinned, Initted=1, LeaveGround tail); Initted gates; the A3 IsThePlayer dual dispatch in apply_current_movement / ReportExhaustion / SetWeenieObject / SetPhysicsObject (a remote player routes INTERPRETED — the ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0 (XOR guard, None-only-from-Run); adjust_motion creature guard wired (TS-34 retired); PhysicsBody.LastMoveWasAutonomous + set_local_velocity(autonomous). Port discovery: retail's apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600 ARE the already-shipped D6.2a/funnel functions — the dual dispatch composes them instead of duplicating. App cutover (orchestrator): the skipTransitionLink flag + both K-fix18 call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes apply_current_movement's interpreted branch through the REAL funnel dispatch when a sink is bound — so a remote's LeaveGround engages Falling via the contact-gated funnel, replacing the forced SetCycle (J19); the per-remote MotionTableDispatchSink is now PERSISTENT (EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations + InitializeMotionTables seams, idempotent from both the UM and VectorUpdate paths; wire velocity re-applied after LeaveGround so it stays authoritative). Player: seams bound to the player sequencer; the controller's grounded→airborne EDGE now fires LeaveGround (jump() clears OnWalkable and the same frame's transition detection fires it — retail's order; walk-off-a-ledge gets the momentum fallback + link strip it never had); the manual jump-block LeaveGround deleted; LastMoveWasAutonomous set at the controller chokepoint (W6 refines). Trace S8 re-expressed as the retail mechanism (Falling dispatch + RemoveAllLinkAnimations = same final state the flag produced). 43 new lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78 added. Full suite: 3,665 passed. Live smoke: in-world clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
af4764443f
commit
e214acdf23
8 changed files with 1575 additions and 124 deletions
|
|
@ -102,7 +102,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 72 rows
|
||||
## 3. Documented approximation (AP) — 73 rows
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -179,9 +179,10 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
| AP-74 | **K-fix18 `skipTransitionLink`** post-Q4 expression: `SetCycle(..., skipTransitionLink:true)` strips the just-dispatched transition links via `CSequence.RemoveAllLinkAnimations` so Falling engages instantly on jump start; retail gets the same strip from `LeaveGround` → `CPhysicsObj::RemoveLinkAnimations` (0x0050fe20) fired by the physics layer detecting ground departure (R2-Q4 rehost of the 2026-04-26 K-fix18) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` skip branch); GameWindow :4830/:10201 callers | Same primitive retail uses, fired from the caller instead of the ground-transition event; pending-queue tick counts intentionally not zeroed (retail's RemoveLinkAnimations doesn't touch the manager queue — the countdown chain absorbs the stall) | If a code path sets the flag outside a genuine ground-departure, links retail would play get eaten; queue completion for the stripped entry reports late (next sentinel) | `LeaveGround` 0x00528b00 → `RemoveLinkAnimations` 0x0050fe20; retire in R3-W4 (flag + call sites deleted, LeaveGround fires it) |
|
||||
| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
|
||||
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
||||
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
|
||||
| AP-78 | **`MotionInterpreter.RemoveLinkAnimations`/`InitializeMotionTables` are no-op `Action?` seams** — retail's `CPhysicsObj::RemoveLinkAnimations` (0x0050fe20, called from `HitGround`/`LeaveGround`) and `CPhysicsObj::InitializeMotionTables` (called from `enter_default_state`) have no real implementation bound at the Core layer; this is ALSO the retail mechanism K-fix18's App-side `SetCycle(skipTransitionLink:true)` hack (AP-74) stands in for — R3-W4 ports the CALL SITES (`HitGround`/`LeaveGround`/`EnterDefaultState` all invoke the seam), but the App-side binding to the entity's `AnimationSequencer`/`CSequence` core, and the K-fix18 hack's deletion, are a separate (orchestrator) commit | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`RemoveLinkAnimations`, `InitializeMotionTables` properties) | Matches the existing `UnstickFromObject`/`InterruptCurrentMovement` `Action?` seam convention (R3-W2/W3); Core has no GL/sequencer dependency to bind against (Code Structure Rule #2) | Until the App layer binds `RemoveLinkAnimations`, `HitGround`/`LeaveGround`/`EnterDefaultState` silently skip the link-strip — no different from before R3-W4 (K-fix18 still covers it) until the orchestrator's binding + K-fix18 deletion land together | `CPhysicsObj::RemoveLinkAnimations` 0x0050fe20; `CPhysicsObj::InitializeMotionTables`; retire (seam→binding) when the orchestrator wires the App-side call, AP-74 retires in the SAME commit that deletes K-fix18 |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 36 rows (TS-37 is a retired-row historical note, not an active count)
|
||||
|
||||
|
|
@ -219,10 +220,10 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
|
||||
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
|
||||
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping) is a dedicated follow-up slice | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0 |
|
||||
| TS-34 | `adjust_motion`'s creature guard is a no-op — retail returns early for a non-creature weenie (`!IsCreature()`), but acdream's `IWeenieObject` has no `IsCreature()`, so the guard always proceeds | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`adjust_motion`) | Correct today: the only caller of the local `adjust_motion`/`apply_raw_movement` path is the player (a creature); no non-creature drives it | If a non-creature weenie is ever routed through `adjust_motion` (e.g. a missile with a weenie), its motion would be wrongly normalized instead of skipped | `CMotionInterp::adjust_motion` 0x00528010 creature guard; add `IsCreature` to IWeenieObject |
|
||||
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain; the WRITE side (per-cell contact-plane constraint tracking — a mover pinned between opposing walkable surfaces / doorway jamming) doesn't exist yet | A body that retail would consider fully constrained (and thus refuse to jump, 0x47) never gets blocked here — jumps succeed in geometry retail would reject. Low practical risk today: the constrained-mover scenario (doorway wedge, opposing-wall pin) is rare and mostly a physics-digest topic already tracked separately | `CPhysicsObj::IsFullyConstrained` 0x0050f730; `jump_is_allowed` 0x005282b0 |
|
||||
| TS-36 | `MotionInterpreter.InterruptCurrentMovement` is a no-op `Action?` seam — retail's `CPhysicsObj::interrupt_current_movement` (called unconditionally at the top of `jump()` and inside `StopCompletely`) has no real implementation to call yet | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`InterruptCurrentMovement`, R3-W3; called from `jump()`) | R4 (MoveToManager) is where `cancel_moveto`/in-flight-transition-interrupt actually lands; until then there is no in-flight transition to cancel (no MoveToManager exists), so the no-op is behaviorally inert, not a masked bug | Once MoveToManager exists (R4) and a jump fires mid-auto-walk/mid-moveto without this seam being wired to the real cancel, the queued transition would keep running alongside the new jump — silent double-motion | `CPhysicsObj::interrupt_current_movement`; `jump` 0x00528780 @305800 |
|
||||
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
||||
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,11 @@ public sealed class PlayerMovementController
|
|||
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
|
||||
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
|
||||
_motion = new MotionInterpreter(_body, _weenie);
|
||||
// R3-W4 (A3): the local player's movement is input-driven —
|
||||
// movement_is_autonomous true so apply_current_movement's dual
|
||||
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
|
||||
// R3-W6 refines this per-motion (server-controlled MoveTo clears it).
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
}
|
||||
|
||||
public void SetCharacterSkills(int runSkill, int jumpSkill)
|
||||
|
|
@ -1008,12 +1013,13 @@ public sealed class PlayerMovementController
|
|||
var jumpResult = _motion.jump(_jumpExtent);
|
||||
if (jumpResult == WeenieError.None)
|
||||
{
|
||||
// Capture jump_v_z BEFORE LeaveGround() — that call resets
|
||||
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
|
||||
// after which GetJumpVZ() returns 0 because the extent
|
||||
// gate at the top of the function fires.
|
||||
// R3-W4 (J7): the manual LeaveGround call is DELETED —
|
||||
// jump() clears OnWalkable, and the SAME frame's
|
||||
// grounded→airborne edge (section 5's transition detection)
|
||||
// fires _motion.LeaveGround() exactly where retail's
|
||||
// transition sweep does. Capture jump_v_z NOW: the edge's
|
||||
// LeaveGround resets JumpExtent to 0 later this frame.
|
||||
float jumpVz = _motion.GetJumpVZ();
|
||||
_motion.LeaveGround();
|
||||
outJumpExtent = _jumpExtent;
|
||||
// D6.2: get_state_velocity() is now correct for all directions
|
||||
// (apply_raw_movement normalized backward/strafe above), so the
|
||||
|
|
@ -1266,6 +1272,16 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
|
||||
// R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's
|
||||
// LeaveGround (0x00528b00) — jump launches (jump() cleared
|
||||
// OnWalkable earlier this frame) AND walk-off-a-ledge both route
|
||||
// here, replacing the old manual jump-block call (and giving
|
||||
// ledge departures the momentum fallback + link strip they never
|
||||
// had). Edge = grounded last frame, airborne now; the landing
|
||||
// branch above already fires HitGround on the opposite edge.
|
||||
if (!_body.OnWalkable && !_wasAirborneLastFrame)
|
||||
_motion.LeaveGround();
|
||||
|
||||
_wasAirborneLastFrame = !_body.OnWalkable;
|
||||
UpdateCellId(resolveResult.CellId, "resolver");
|
||||
|
||||
|
|
|
|||
|
|
@ -412,6 +412,11 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
public AcDream.Core.Physics.MotionInterpreter Motion;
|
||||
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
|
||||
/// once by EnsureRemoteMotionBindings; also bound as
|
||||
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
|
||||
/// cycles through the motion-table backend).</summary>
|
||||
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
|
||||
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
|
||||
public double LastServerPosTime;
|
||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
|
|
@ -4176,6 +4181,45 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
|
||||
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
|
||||
/// callbacks), <c>Motion.DefaultSink</c> (so
|
||||
/// <c>apply_current_movement</c>'s interpreted branch dispatches cycles
|
||||
/// — the retail mechanism behind the deleted K-fix18 forced-Falling),
|
||||
/// and the <c>RemoveLinkAnimations</c>/<c>InitializeMotionTables</c>
|
||||
/// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 /
|
||||
/// InitializeMotionTables). Idempotent; safe from both the UM path and
|
||||
/// the VectorUpdate path regardless of arrival order.
|
||||
/// </summary>
|
||||
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||||
RemoteMotion rm, AnimatedEntity ae)
|
||||
{
|
||||
if (ae.Sequencer is null)
|
||||
return rm.Sink;
|
||||
if (rm.Sink is not null)
|
||||
return rm.Sink;
|
||||
|
||||
var rmForSink = rm;
|
||||
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
|
||||
{
|
||||
TurnApplied = (turnMotion, turnSpeed) =>
|
||||
{
|
||||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||||
? -System.MathF.Abs(turnSpeed)
|
||||
: turnSpeed;
|
||||
rmForSink.ObservedOmega = new System.Numerics.Vector3(
|
||||
0f, 0f, -(System.MathF.PI / 2f) * signed);
|
||||
},
|
||||
TurnStopped = () =>
|
||||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||||
};
|
||||
rm.Motion.DefaultSink = rm.Sink;
|
||||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
||||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
||||
return rm.Sink;
|
||||
}
|
||||
|
||||
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||||
|
|
@ -4623,31 +4667,13 @@ public sealed class GameWindow : IDisposable
|
|||
ims.Actions = actionList;
|
||||
}
|
||||
|
||||
// R2-Q5: funnel dispatches go STRAIGHT into the motion-
|
||||
// table stack (GetObjectSequence + is_allowed decide) —
|
||||
// RemoteMotionSink's single-cycle pick + fallback chains
|
||||
// are deleted (AP-73 retired). The callbacks are the
|
||||
// ObservedOmega seam: seed remote rotation from the wire
|
||||
// turn this tick (retail rotates from sequence omega in
|
||||
// the per-tick apply_physics chain — R6; register row).
|
||||
AcDream.Core.Physics.Motion.MotionTableDispatchSink? sink = null;
|
||||
if (ae.Sequencer is not null)
|
||||
{
|
||||
var rmForSink = remoteMot;
|
||||
sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
|
||||
{
|
||||
TurnApplied = (turnMotion, turnSpeed) =>
|
||||
{
|
||||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||||
? -System.MathF.Abs(turnSpeed)
|
||||
: turnSpeed;
|
||||
rmForSink.ObservedOmega = new System.Numerics.Vector3(
|
||||
0f, 0f, -(System.MathF.PI / 2f) * signed);
|
||||
},
|
||||
TurnStopped = () =>
|
||||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||||
};
|
||||
}
|
||||
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
|
||||
// motion-table stack (GetObjectSequence + is_allowed
|
||||
// decide). The sink is PERSISTENT per remote and also
|
||||
// bound as Motion.DefaultSink + the RemoveLink/InitTables
|
||||
// seams (EnsureRemoteMotionBindings) so LeaveGround/
|
||||
// HitGround dispatch through the same backend.
|
||||
var sink = EnsureRemoteMotionBindings(remoteMot, ae);
|
||||
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
||||
}
|
||||
}
|
||||
|
|
@ -4813,26 +4839,24 @@ public sealed class GameWindow : IDisposable
|
|||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
|
||||
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
|
||||
// K-fix10 (2026-04-26): force the Falling animation cycle on
|
||||
// the remote so the legs match the arc. Mirrors the local
|
||||
// player's UpdatePlayerAnimation path which sets
|
||||
// animCommand = Falling whenever !IsOnGround.
|
||||
//
|
||||
// K-fix18 (2026-04-26): pass skipTransitionLink:true so the
|
||||
// RunForward→Falling transition frames don't play first.
|
||||
// Without that flag the remote stood still for ~100 ms at
|
||||
// the start of the jump while the link drained, then
|
||||
// folded into Falling. Skipping the link makes the pose
|
||||
// engage immediately on jump start.
|
||||
// R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
|
||||
// The remote's ground departure fires LeaveGround (0x00528b00):
|
||||
// strips pending transition links (the RemoveLinkAnimations
|
||||
// seam) + re-applies movement through DefaultSink, whose
|
||||
// contact-gated funnel dispatch engages Falling — no forced
|
||||
// SetCycle, no skip flag. The wire velocity/omega are re-applied
|
||||
// AFTER so they stay authoritative over LeaveGround's
|
||||
// state-derived velocity write (adaptation note: retail's
|
||||
// equivalence comes from the per-tick transition-sweep order —
|
||||
// R6 scope).
|
||||
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
|
||||
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
|
||||
&& ae.Sequencer is not null)
|
||||
{
|
||||
uint style = ae.Sequencer.CurrentStyle != 0
|
||||
? ae.Sequencer.CurrentStyle
|
||||
: 0x8000003Du; // NonCombat default
|
||||
ae.Sequencer.SetCycle(style, AcDream.Core.Physics.MotionCommand.Falling, 1.0f,
|
||||
skipTransitionLink: true);
|
||||
EnsureRemoteMotionBindings(rm, ae);
|
||||
rm.Motion.LeaveGround();
|
||||
rm.Body.Velocity = update.Velocity;
|
||||
rm.Body.Omega = update.Omega;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -10241,14 +10265,11 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
animScale = s;
|
||||
}
|
||||
// K-fix18 (2026-04-26): when transitioning into Falling
|
||||
// (jump start), skip the link so the legs engage Falling
|
||||
// immediately. Without this the local player visibly
|
||||
// stood still for ~100 ms at the start of every jump
|
||||
// while the RunForward→Falling transition link drained.
|
||||
// For everything else (Walk → Run, Run → Ready, etc.) we
|
||||
// keep the link so transitions stay smooth.
|
||||
bool skipLink = animCommand == AcDream.Core.Physics.MotionCommand.Falling;
|
||||
// R3-W4: the K-fix18 skipLink flag is DELETED — the instant
|
||||
// Falling engage comes from the controller's grounded→airborne
|
||||
// edge firing MotionInterpreter.LeaveGround, whose
|
||||
// RemoveLinkAnimations seam (bound at sequencer creation)
|
||||
// strips pending links exactly where retail does.
|
||||
|
||||
// #45 (2026-05-06): scale sidestep speedMod to match ACE's
|
||||
// wire formula. PlayerMovementController hands us a raw
|
||||
|
|
@ -10270,8 +10291,7 @@ public sealed class GameWindow : IDisposable
|
|||
* 0.5f;
|
||||
}
|
||||
|
||||
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale,
|
||||
skipTransitionLink: skipLink);
|
||||
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
|
||||
}
|
||||
|
||||
// Legacy path: update the manual slerp fields (for entities without sequencer)
|
||||
|
|
@ -12965,6 +12985,13 @@ public sealed class GameWindow : IDisposable
|
|||
&& playerAE.Sequencer is { } playerSeq)
|
||||
{
|
||||
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
|
||||
// R3-W4: bind the player interp's retail seams to the player
|
||||
// sequencer — LeaveGround/HitGround strip transition links here
|
||||
// (the K-fix18 replacement); DefaultSink stays null until R3-W6
|
||||
// (UpdatePlayerAnimation still drives the player's cycles).
|
||||
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
||||
_playerController.Motion.InitializeMotionTables =
|
||||
() => playerSeq.Manager.InitializeState();
|
||||
}
|
||||
|
||||
var q = playerEntity.Rotation;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ public sealed class DatCollectionLoader : IAnimationLoader
|
|||
// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every
|
||||
// public member's signature (the API-migration table in the gap map is the
|
||||
// contract) and re-expresses each one over the core. Invented mechanisms that
|
||||
// are still R2/R3 scope (K-fix18 skipTransitionLink, Fix B locomotion
|
||||
// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion
|
||||
// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega
|
||||
// synthesis constants) SURVIVE unchanged at the adapter level per the gap
|
||||
// map's "Invented behaviors NOT in the gap list" note.
|
||||
|
|
@ -339,15 +339,7 @@ public sealed class AnimationSequencer
|
|||
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
|
||||
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
|
||||
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
|
||||
/// <param name="skipTransitionLink">K-fix18 (2026-04-26): when true, do
|
||||
/// NOT enqueue the transition-link frames between the previous and
|
||||
/// new cycle. Used when the caller wants the new cycle to engage
|
||||
/// instantly — e.g. swapping to Falling on a jump start, where the
|
||||
/// RunForward→Falling link is a short "stop running" pose that
|
||||
/// makes the jump look delayed (legs stand still for ~100 ms while
|
||||
/// the link drains, then fold into Falling). Defaults to false to
|
||||
/// preserve normal smooth transitions for everything else.</param>
|
||||
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
|
||||
public void SetCycle(uint style, uint motion, float speedMod = 1f)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
|
|
@ -394,17 +386,11 @@ public sealed class AnimationSequencer
|
|||
uint dispatchResult = PerformMovement(
|
||||
MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed));
|
||||
|
||||
// K-fix18 (2026-04-26, register row survives → R3): instant-engage
|
||||
// cases (Falling on jump start) strip the just-selected transition
|
||||
// links so the new cycle starts immediately. Post-Q4 expression:
|
||||
// the retail primitive CPhysicsObj::RemoveLinkAnimations
|
||||
// (0x0050fe20 → CSequence::remove_all_link_animations) — exactly
|
||||
// what R3-W4's LeaveGround will call, at which point this flag and
|
||||
// its GameWindow call sites are deleted. The pending-queue entry's
|
||||
// tick count intentionally stays (retail's RemoveLinkAnimations
|
||||
// does not touch the manager queue; the countdown chain absorbs it).
|
||||
if (skipTransitionLink)
|
||||
_core.RemoveAllLinkAnimations();
|
||||
// R3-W4: the K-fix18 skipTransitionLink flag is DELETED — the
|
||||
// instant-Falling engage is now retail's own mechanism: the entity's
|
||||
// MotionInterpreter.LeaveGround (0x00528b00) fires the
|
||||
// RemoveLinkAnimations seam (bound to this sequencer's
|
||||
// RemoveAllLinkAnimations) when the body leaves the ground.
|
||||
|
||||
// Failed dispatch (missing cycle for this style, is_allowed reject):
|
||||
// retail leaves sequence AND state untouched — no synthesis either.
|
||||
|
|
@ -446,6 +432,18 @@ public sealed class AnimationSequencer
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4: the retail link-strip primitive
|
||||
/// (<c>CPhysicsObj::RemoveLinkAnimations</c> 0x0050fe20 →
|
||||
/// <c>CSequence::remove_all_link_animations</c> 0x00524ca0). The App
|
||||
/// binds the entity's <c>MotionInterpreter.RemoveLinkAnimations</c> seam
|
||||
/// here so HitGround/LeaveGround strip pending transition links exactly
|
||||
/// where retail does. The pending-queue tick counts intentionally stay
|
||||
/// (retail's primitive does not touch the manager queue; the countdown
|
||||
/// chain absorbs it).
|
||||
/// </summary>
|
||||
public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations();
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5: run retail's <c>enter_default_state</c> analog now if it
|
||||
/// hasn't run yet (idempotent). Spawn paths call this so an entity with
|
||||
|
|
|
|||
|
|
@ -439,10 +439,12 @@ public interface IWeenieObject
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>CWeenieObject::IsCreature</c>. Non-creature weenies bypass
|
||||
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240)
|
||||
/// and the run remap in <c>adjust_motion</c>. Players, NPCs, and
|
||||
/// the ground-contact gate in <c>contact_allows_move</c> (0x00528240),
|
||||
/// the run remap in <c>adjust_motion</c> (wired R3-W4, closes J18 —
|
||||
/// register TS-34 retired), and the <see cref="HitGround"/>/
|
||||
/// <see cref="LeaveGround"/> creature gates. Players, NPCs, and
|
||||
/// monsters are creatures — default true keeps existing implementers
|
||||
/// retail-correct (register TS-34 tracks the adjust_motion side).
|
||||
/// retail-correct.
|
||||
/// </summary>
|
||||
bool IsCreature() => true;
|
||||
|
||||
|
|
@ -639,6 +641,76 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// </summary>
|
||||
public Action? InterruptCurrentMovement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4 seam standing in for retail <c>CPhysicsObj::RemoveLinkAnimations</c>
|
||||
/// (0x0050fe20, decomp §9 summary table) → <c>CPartArray</c> →
|
||||
/// <c>CSequence::remove_all_link_animations</c> — called from
|
||||
/// <see cref="HitGround"/>/<see cref="LeaveGround"/> (§4a/4b) whenever
|
||||
/// the ground-transition body actually runs. This is retail's REAL
|
||||
/// mechanism for "leaving the ground strips any pending link
|
||||
/// animation so Falling engages instantly" — the K-fix18 App-side
|
||||
/// forced-<c>SetCycle(skipTransitionLink:true)</c> hack this seam
|
||||
/// replaces is deleted in the orchestrator's App-side half (register
|
||||
/// row retirement rides with that deletion, not this commit — Core
|
||||
/// has no K-fix18 code to remove). The App layer binds this to the
|
||||
/// entity's <c>AnimationSequencer</c>/<c>CSequence</c> core
|
||||
/// (<c>RemoveAllLinkAnimations</c>-equivalent) per entity, matching the
|
||||
/// existing <see cref="UnstickFromObject"/>/<see cref="InterruptCurrentMovement"/>
|
||||
/// <c>Action?</c> seam convention. Optional — a null binding is a
|
||||
/// silent no-op (safe default for tests and for any physics body with
|
||||
/// no sequencer attached yet).
|
||||
/// </summary>
|
||||
public Action? RemoveLinkAnimations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4 no-op seam standing in for retail
|
||||
/// <c>CPhysicsObj::InitializeMotionTables</c>, called from
|
||||
/// <see cref="EnterDefaultState"/> (§4d, raw @306124:
|
||||
/// <c>CPhysicsObj::InitializeMotionTables(this->physics_obj);</c>).
|
||||
/// This re-seeds the physics object's <c>MotionTableManager</c>/motion
|
||||
/// table stack — a manager-side (App-bound) concern, not something
|
||||
/// <c>MotionInterpreter</c> itself owns state for. Register row: no-op
|
||||
/// until the App layer binds it to the entity's motion-table
|
||||
/// initialization; matches the <see cref="RemoveLinkAnimations"/> seam
|
||||
/// convention.
|
||||
/// </summary>
|
||||
public Action? InitializeMotionTables { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4 — retail <c>CMotionInterp::initted</c> (set by
|
||||
/// <see cref="EnterDefaultState"/>, raw @306152: <c>this->initted =
|
||||
/// 1;</c>). Gates <see cref="apply_current_movement"/> and
|
||||
/// <see cref="ReportExhaustion"/>'s entry (A3: <c>physics_obj != 0
|
||||
/// && initted != 0</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Constructor default is <c>true</c>, NOT retail's <c>false</c>
|
||||
/// (see the final-report note on this divergence).</b> Retail's
|
||||
/// <c>CMotionInterp</c> is never used standalone — every real
|
||||
/// construction path (<c>MovementManager::PerformMovement</c>'s
|
||||
/// lazy-create, <c>MovementManager::Create</c>) immediately calls
|
||||
/// <see cref="EnterDefaultState"/> (retail's <c>enter_default_state</c>)
|
||||
/// before the interpreter is exposed to any caller, so <c>initted</c>
|
||||
/// is always <c>1</c> by the time application code can observe it.
|
||||
/// acdream's <c>MotionInterpreter</c> constructor is used directly by
|
||||
/// ~40 pre-existing tests AND by both App call sites
|
||||
/// (<c>PlayerMovementController</c>, <c>GameWindow</c>) as a
|
||||
/// complete, immediately-usable object with no separate
|
||||
/// "enter default state" step — porting retail's literal
|
||||
/// <c>false</c> default would silently no-op every one of those
|
||||
/// call sites' <c>apply_current_movement</c>/<c>ReportExhaustion</c>
|
||||
/// calls until something remembers to call
|
||||
/// <see cref="EnterDefaultState"/> (which nothing currently does).
|
||||
/// Defaulting <c>true</c> here is the C# equivalent of "the
|
||||
/// constructor already did what <c>enter_default_state</c> would have
|
||||
/// done to this flag" — <see cref="EnterDefaultState"/> remains the
|
||||
/// verbatim, fully-testable retail method for callers (tests, and a
|
||||
/// future App reset-to-idle path) that need the REST of its behavior
|
||||
/// (state reset, sentinel enqueue, <see cref="LeaveGround"/> tail).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool Initted { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional accessor for the owning entity's current animation cycle
|
||||
/// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity
|
||||
|
|
@ -1038,12 +1110,12 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Creature guard (retail <c>weenie.IsCreature()</c>) is UNWIRED.</b>
|
||||
/// <see cref="IWeenieObject"/> does not currently expose an
|
||||
/// <c>IsCreature</c> query, so this port always proceeds (equivalent to
|
||||
/// "no weenie" or "is a creature" — true for the player, which is the
|
||||
/// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject
|
||||
/// extension; do not fabricate a guess at the query here.
|
||||
/// <b>R3-W4 (closes J18, retires register TS-34): creature guard WIRED.</b>
|
||||
/// Retail (raw @305343): <c>if (weenie != 0 && weenie->IsCreature() == 0)
|
||||
/// return;</c> — a weenie present whose <c>IsCreature()</c> returns
|
||||
/// false short-circuits the ENTIRE function (no normalization, no
|
||||
/// hold-key path). <see cref="IWeenieObject.IsCreature"/> has existed
|
||||
/// since W3; this was the one caller left un-gated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="motion">Raw motion command, normalized in place.</param>
|
||||
|
|
@ -1055,7 +1127,8 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// </param>
|
||||
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
|
||||
{
|
||||
// Creature guard unwired — IWeenieObject has no IsCreature(); always proceed.
|
||||
if (WeenieObj is not null && !WeenieObj.IsCreature())
|
||||
return;
|
||||
|
||||
switch (motion)
|
||||
{
|
||||
|
|
@ -1187,26 +1260,145 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
|
||||
}
|
||||
|
||||
// ── FUN_00529210 — apply_current_movement ─────────────────────────────────
|
||||
// ── FUN_00528870 — apply_current_movement ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Apply the current interpreted motion state as a local velocity to the PhysicsBody.
|
||||
/// <c>CMotionInterp::apply_current_movement</c> (0x00528870, decomp §4e,
|
||||
/// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4
|
||||
/// direct-velocity approximation).
|
||||
///
|
||||
/// Decompiled logic (FUN_00529210):
|
||||
/// if PhysicsObj == null: return
|
||||
/// FUN_00524f80() — internal animation state update
|
||||
/// If ForwardCommand == RunForward: update MyRunRate = ForwardSpeed
|
||||
/// Then delegates to DoInterpretedMotion for each active command,
|
||||
/// which ultimately calls set_local_velocity via FUN_00528960.
|
||||
/// <para>
|
||||
/// Verbatim (raw 305838-305857):
|
||||
/// <code>
|
||||
/// if (physics_obj != 0 && initted != 0) {
|
||||
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
|
||||
/// if ((weenie_obj == 0 || eax_2 != 0)
|
||||
/// && movement_is_autonomous(physics_obj) != 0) {
|
||||
/// apply_raw_movement(this, arg2, arg3);
|
||||
/// return;
|
||||
/// }
|
||||
/// apply_interpreted_movement(this, arg2, arg3);
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// In our physics-only port we compute the body-local velocity via
|
||||
/// get_state_velocity() and push it directly to PhysicsBody.set_local_velocity.
|
||||
/// <para>
|
||||
/// A3: the dispatch gate is <c>IsThePlayer</c> (no weenie = treat as
|
||||
/// the player), NOT <c>IsCreature</c> — a remote player weenie
|
||||
/// (<c>IsThePlayer()==false</c>, <c>IsCreature()==true</c>) routes
|
||||
/// INTERPRETED even when <c>movement_is_autonomous</c> is true. This is
|
||||
/// the genuine divergence from ACE's server-side <c>IsCreature</c> gate
|
||||
/// (ACE MotionInterp.cs:430-438) — do not copy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
|
||||
{
|
||||
if (PhysicsObj is null || !Initted)
|
||||
return;
|
||||
|
||||
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
|
||||
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
|
||||
{
|
||||
apply_raw_movement(cancelMoveTo, allowJump);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::apply_raw_movement</c> (0x005287e0, decomp §4e
|
||||
/// referenced body, raw 305817-305834), the <c>(cancelMoveTo,
|
||||
/// allowJump)</c>-arg overload reached from
|
||||
/// <see cref="apply_current_movement"/>'s dual dispatch (A3's
|
||||
/// "IsThePlayer + autonomous" branch), <see cref="ReportExhaustion"/>,
|
||||
/// <see cref="SetWeenieObject"/>, and <see cref="SetPhysicsObject"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim: copy the SEVEN raw-state fields (style + all three
|
||||
/// axis command/speed pairs) into <see cref="InterpretedState"/>,
|
||||
/// normalize each of the three axes IN PLACE via <see cref="adjust_motion"/>
|
||||
/// using THAT axis's own <see cref="RawState"/> hold key, then
|
||||
/// tail-call <c>apply_interpreted_movement(this, arg2, arg3)</c> — the
|
||||
/// SAME retail function (0x00528600) as <see cref="ApplyInterpretedMovement"/>
|
||||
/// (the funnel producer). This is NOT a new function: it is the
|
||||
/// existing <see cref="apply_raw_movement(RawMotionState)"/> overload's
|
||||
/// body (the D6.2a raw-state-orchestrator port), reached here with
|
||||
/// <see cref="RawState"/> itself as the source instead of an externally
|
||||
/// supplied snapshot, plus the previously-missing
|
||||
/// <c>apply_interpreted_movement(arg2, arg3)</c> tail call this
|
||||
/// dispatch site needs.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
|
||||
{
|
||||
if (PhysicsObj is null)
|
||||
return;
|
||||
|
||||
apply_raw_movement(RawState);
|
||||
ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retail <c>apply_interpreted_movement</c> tail reached from BOTH
|
||||
/// dual-dispatch branches of <see cref="apply_current_movement"/> (raw
|
||||
/// 305855/305832 — <c>CMotionInterp::apply_interpreted_movement</c>,
|
||||
/// 0x00528600, the SAME function as the funnel's
|
||||
/// <see cref="ApplyInterpretedMovement"/>, per the raw's identical call
|
||||
/// target at every one of its six call sites).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Physics-only-port body (pre-W4 approximation, SURVIVES per the
|
||||
/// plan's "less invasive" choice — see the final-report note):</b> this
|
||||
/// port has no <see cref="IInterpretedMotionSink"/> wired at this call
|
||||
/// site (retail's real body drives <c>DoInterpretedMotion</c>/
|
||||
/// <c>StopInterpretedMotion</c> through the animation-table backend,
|
||||
/// exactly like <see cref="ApplyInterpretedMovement"/> already does for
|
||||
/// the INBOUND funnel path — but that path always has a sink available
|
||||
/// from its caller, while <c>apply_current_movement</c>'s callers
|
||||
/// (<see cref="HitGround"/>/<see cref="LeaveGround"/>/
|
||||
/// <see cref="ReportExhaustion"/>/hold-key toggles/jump) do not thread
|
||||
/// one through). Kept as the pre-existing direct
|
||||
/// <c>get_state_velocity</c> → grounded <c>set_local_velocity</c> write
|
||||
/// — an acdream adaptation of root-motion-driven velocity, register row
|
||||
/// added same commit — until R6 (root motion drives the body directly
|
||||
/// and this local-only fallback retires in favor of a real sink here
|
||||
/// too).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// R3-W4 (App-bound): the entity's persistent animation-dispatch sink.
|
||||
/// When set, <see cref="ApplyCurrentMovementInterpreted"/> routes the
|
||||
/// REAL <see cref="ApplyInterpretedMovement"/> (retail
|
||||
/// <c>apply_interpreted_movement</c> 0x00528600 — the same function the
|
||||
/// inbound funnel drives), so HitGround/LeaveGround/hold-key re-applies
|
||||
/// dispatch cycles (the airborne-Falling engage) through the
|
||||
/// motion-table backend exactly as retail does. Null (the local player
|
||||
/// until R3-W6, interp-less entities) → the AP-77 physics-only tail.
|
||||
/// </summary>
|
||||
public IInterpretedMotionSink? DefaultSink { get; set; }
|
||||
|
||||
private void ApplyCurrentMovementInterpreted(bool cancelMoveTo, bool allowJump)
|
||||
{
|
||||
if (PhysicsObj is null)
|
||||
return;
|
||||
|
||||
// R3-W4: with a persistent sink bound (remotes), this IS retail's
|
||||
// apply_interpreted_movement — full dispatch through the funnel
|
||||
// body (style → forward-or-Falling → sidestep → turn), which is
|
||||
// what makes LeaveGround engage Falling without the deleted
|
||||
// K-fix18 forced-SetCycle. Without a sink, fall through to the
|
||||
// AP-77 physics-only adaptation below.
|
||||
if (DefaultSink is not null)
|
||||
{
|
||||
ApplyInterpretedMovement(InterpretedState.CurrentStyle, DefaultSink,
|
||||
cancelMoveTo, allowJump);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = cancelMoveTo;
|
||||
_ = allowJump;
|
||||
|
||||
// Decompile writes back MyRunRate when in run state (offset +0x7C).
|
||||
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
||||
MyRunRate = InterpretedState.ForwardSpeed;
|
||||
|
|
@ -1220,10 +1412,159 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
if (PhysicsObj.OnWalkable)
|
||||
{
|
||||
var localVelocity = get_state_velocity();
|
||||
PhysicsObj.set_local_velocity(localVelocity);
|
||||
// AP-77: this adaptation's fallback write has no real opinion on
|
||||
// "was this move autonomous" (retail's real set_local_velocity
|
||||
// call for this dispatch path lives deep inside the animation
|
||||
// backend this stand-in doesn't have — see the seam's doc
|
||||
// comment). Preserve whatever LastMoveWasAutonomous already
|
||||
// holds rather than resetting it, so a caller like LeaveGround
|
||||
// (which just set it true via its OWN direct set_local_velocity
|
||||
// call, autonomous=1 per raw @305763-305765) isn't clobbered by
|
||||
// this immediately-following re-sync tail call.
|
||||
PhysicsObj.set_local_velocity(localVelocity, PhysicsObj.LastMoveWasAutonomous);
|
||||
}
|
||||
}
|
||||
|
||||
// ── FUN_005288d0 — ReportExhaustion ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::ReportExhaustion</c> (0x005288d0, decomp §4c
|
||||
/// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and
|
||||
/// dual-dispatch predicate to <see cref="apply_current_movement"/>,
|
||||
/// with HARDCODED <c>(0, 0)</c> dispatch args (raw 305874/305878) —
|
||||
/// the retail caller (<c>CPhysicsObj::report_exhaustion</c> 0x0050fdd0
|
||||
/// → <c>MovementManager::ReportExhaustion</c> 0x00524360) passes no
|
||||
/// per-call parameters through.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 305861-305880):
|
||||
/// <code>
|
||||
/// if (physics_obj != 0 && initted != 0) {
|
||||
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer();
|
||||
/// if ((weenie_obj == 0 || eax_2 != 0)
|
||||
/// && movement_is_autonomous(physics_obj) != 0) {
|
||||
/// apply_raw_movement(this, 0, 0);
|
||||
/// return;
|
||||
/// }
|
||||
/// apply_interpreted_movement(this, 0, 0);
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void ReportExhaustion()
|
||||
{
|
||||
if (PhysicsObj is null || !Initted)
|
||||
return;
|
||||
|
||||
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
|
||||
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
|
||||
{
|
||||
apply_raw_movement(cancelMoveTo: false, allowJump: false);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
|
||||
// ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ───────
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::SetWeenieObject</c> (0x00528920, decomp §4f
|
||||
/// @305884, FULL BODY, W0-pins.md A3). Assigns <see cref="WeenieObj"/>,
|
||||
/// then — if already physics-bound AND <see cref="Initted"/> — re-applies
|
||||
/// movement via the SAME dual-dispatch predicate as
|
||||
/// <see cref="apply_current_movement"/>, EXCEPT the weenie tested for
|
||||
/// <c>IsThePlayer</c> is the INCOMING <paramref name="weenie"/> (not
|
||||
/// <see cref="WeenieObj"/> post-assignment — they're the same value
|
||||
/// here, but retail's raw makes the incoming-arg read explicit: a null
|
||||
/// incoming weenie takes the SAME branch as a non-null-but-IsThePlayer
|
||||
/// weenie, both via <c>goto label_528946</c>). Dispatch args are
|
||||
/// hardcoded <c>(1, 0)</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 305884-305907):
|
||||
/// <code>
|
||||
/// bool noPhysics = (physics_obj == 0);
|
||||
/// weenie_obj = arg2;
|
||||
/// if (!noPhysics && initted != 0) {
|
||||
/// if (arg2 == 0) {
|
||||
/// label_528946:
|
||||
/// if (movement_is_autonomous(physics_obj) != 0) {
|
||||
/// apply_raw_movement(this, 1, 0);
|
||||
/// return;
|
||||
/// }
|
||||
/// } else if (arg2->IsThePlayer() != 0)
|
||||
/// goto label_528946;
|
||||
/// apply_interpreted_movement(this, 1, 0);
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SetWeenieObject(IWeenieObject? weenie)
|
||||
{
|
||||
WeenieObj = weenie;
|
||||
|
||||
if (PhysicsObj is null || !Initted)
|
||||
return;
|
||||
|
||||
bool isThePlayer = weenie is null || weenie.IsThePlayer();
|
||||
if (isThePlayer && PhysicsObj.LastMoveWasAutonomous)
|
||||
{
|
||||
apply_raw_movement(cancelMoveTo: true, allowJump: false);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::SetPhysicsObject</c> (0x00528970, decomp §4g
|
||||
/// @305911, FULL BODY, W0-pins.md A3). Assigns <see cref="PhysicsObj"/>
|
||||
/// unconditionally FIRST, then — if the incoming
|
||||
/// <paramref name="physicsObj"/> is non-null AND <see cref="Initted"/> —
|
||||
/// re-applies movement via the standard dual-dispatch predicate (this
|
||||
/// time reading <see cref="WeenieObj"/>, unchanged by this call).
|
||||
/// Dispatch args are hardcoded <c>(1, 0)</c>, matching
|
||||
/// <see cref="SetWeenieObject"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 305911-305932):
|
||||
/// <code>
|
||||
/// this->physics_obj = arg2;
|
||||
/// if (arg2 != 0 && initted != 0) {
|
||||
/// if (weenie_obj != 0) eax_1 = weenie_obj->IsThePlayer();
|
||||
/// if ((weenie_obj == 0 || eax_1 != 0)
|
||||
/// && movement_is_autonomous(physics_obj) != 0) {
|
||||
/// apply_raw_movement(this, 1, 0);
|
||||
/// return;
|
||||
/// }
|
||||
/// apply_interpreted_movement(this, 1, 0);
|
||||
/// }
|
||||
/// </code>
|
||||
/// Note the entry gate is on the INCOMING <c>arg2</c> (not the
|
||||
/// just-assigned <c>this->physics_obj</c> — same value, but the raw
|
||||
/// tests the parameter register directly), unlike
|
||||
/// <see cref="SetWeenieObject"/>'s entry gate which tests the
|
||||
/// PRE-assignment <c>physics_obj</c> snapshot (<c>noPhysics</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SetPhysicsObject(PhysicsBody? physicsObj)
|
||||
{
|
||||
PhysicsObj = physicsObj;
|
||||
|
||||
if (physicsObj is null || !Initted)
|
||||
return;
|
||||
|
||||
bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer();
|
||||
if (isThePlayer && physicsObj.LastMoveWasAutonomous)
|
||||
{
|
||||
apply_raw_movement(cancelMoveTo: true, allowJump: false);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: false);
|
||||
}
|
||||
|
||||
// ── FUN_00527a50 — jump_charge_is_allowed ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1832,51 +2173,245 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
return WeenieError.YouCantJumpFromThisPosition;
|
||||
}
|
||||
|
||||
// ── FUN_00529710 — LeaveGround ────────────────────────────────────────────
|
||||
// ── FUN_00528b00 — LeaveGround ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called when the body becomes airborne. Applies the leave-ground velocity
|
||||
/// and resets the jump state.
|
||||
/// <c>CMotionInterp::LeaveGround</c> (0x00528b00, decomp §4b @306022,
|
||||
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
|
||||
/// LeaveGround leg). Called when the body becomes airborne (also
|
||||
/// tail-called unconditionally from <see cref="EnterDefaultState"/>,
|
||||
/// §4d).
|
||||
///
|
||||
/// Decompiled logic (FUN_00529710):
|
||||
/// if PhysicsObj == null: return
|
||||
/// velocity = GetLeaveGroundVelocity()
|
||||
/// PhysicsObj.set_local_velocity(velocity)
|
||||
/// StandingLongJump = false
|
||||
/// JumpExtent = 0
|
||||
/// <para>
|
||||
/// Verbatim (raw 306022-306040, quoted in full in the final-report):
|
||||
/// creature gate (<c>weenie_obj == 0 || IsCreature() != 0</c>) AND
|
||||
/// state-0x400 (Gravity) gate — SAME shape as <see cref="HitGround"/>.
|
||||
/// When both pass: compute the launch velocity via
|
||||
/// <see cref="GetLeaveGroundVelocity"/>, apply it with
|
||||
/// <c>set_local_velocity(&v, autonomous=1)</c> (the literal
|
||||
/// <c>1</c> arg — <see cref="PhysicsBody.LastMoveWasAutonomous"/>
|
||||
/// becomes true), reset <see cref="StandingLongJump"/> and
|
||||
/// <see cref="JumpExtent"/> to their zero/false defaults (the jump
|
||||
/// charge is consumed the instant you actually leave the ground), then
|
||||
/// <see cref="RemoveLinkAnimations"/> + <c>apply_current_movement(0,
|
||||
/// 0)</c> (the same re-sync <see cref="HitGround"/> performs).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void LeaveGround()
|
||||
{
|
||||
if (PhysicsObj is null)
|
||||
return;
|
||||
|
||||
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
|
||||
if (!isCreature)
|
||||
return;
|
||||
|
||||
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
|
||||
return;
|
||||
|
||||
var velocity = GetLeaveGroundVelocity();
|
||||
PhysicsObj.set_local_velocity(velocity);
|
||||
PhysicsObj.set_local_velocity(velocity, autonomous: true);
|
||||
|
||||
StandingLongJump = false;
|
||||
JumpExtent = 0f;
|
||||
|
||||
RemoveLinkAnimations?.Invoke();
|
||||
apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
|
||||
// ── FUN_005296d0 — HitGround ──────────────────────────────────────────────
|
||||
// ── FUN_00528ac0 — HitGround ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called when the body lands on a walkable surface.
|
||||
/// <c>CMotionInterp::HitGround</c> (0x00528ac0, decomp §4a @305996,
|
||||
/// FULL BODY — replaces the pre-W4 approximation, closes J8's
|
||||
/// HitGround leg). Called when the body lands on a walkable surface.
|
||||
///
|
||||
/// Decompiled logic (FUN_005296d0):
|
||||
/// if PhysicsObj == null: return
|
||||
/// if WeenieObj != null AND NOT creature: return
|
||||
/// if Gravity flag not set: return
|
||||
/// apply_current_movement(false, true)
|
||||
/// <para>
|
||||
/// Verbatim (raw 305996-306014):
|
||||
/// <code>
|
||||
/// if (physics_obj != 0) {
|
||||
/// if (weenie_obj != 0) eax_2 = weenie_obj->IsCreature();
|
||||
/// if (weenie_obj == 0 || eax_2 != 0) {
|
||||
/// if (physics_obj != 0 && (state & 0x400) != 0) {
|
||||
/// RemoveLinkAnimations(physics_obj);
|
||||
/// apply_current_movement(this, 0, 0);
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A3 note: this creature gate uses <c>IsCreature</c> (vtable +0x2c),
|
||||
/// NOT <c>IsThePlayer</c> (+0x14) — the anti-artifact proof for A3's
|
||||
/// pin (BN distinguishes the two slots locally, ~0x250 bytes from the
|
||||
/// IsThePlayer-gated functions).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void HitGround()
|
||||
{
|
||||
if (PhysicsObj is null)
|
||||
return;
|
||||
|
||||
bool isCreature = WeenieObj is null || WeenieObj.IsCreature();
|
||||
if (!isCreature)
|
||||
return;
|
||||
|
||||
if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity))
|
||||
return;
|
||||
|
||||
apply_current_movement(cancelMoveTo: false, allowJump: true);
|
||||
RemoveLinkAnimations?.Invoke();
|
||||
apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
|
||||
// ── FUN_00528c80 — enter_default_state ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::enter_default_state</c> (0x00528c80, decomp §4d
|
||||
/// @306124, FULL BODY, W0-pins.md A8, closes J10). Resets both motion
|
||||
/// states to fresh defaults, re-initializes the physics object's
|
||||
/// motion tables, APPENDS the canonical <c>{0, Ready, 0}</c> sentinel
|
||||
/// to <see cref="PendingMotions"/> (NO drain — pre-existing queued
|
||||
/// nodes survive, A8), marks <see cref="Initted"/>, then tail-calls
|
||||
/// <see cref="LeaveGround"/> unconditionally.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 306124-306154):
|
||||
/// <code>
|
||||
/// raw_state = RawMotionState(); // fresh ctor defaults
|
||||
/// interpreted_state = InterpretedMotionState(); // fresh ctor defaults
|
||||
/// CPhysicsObj::InitializeMotionTables(physics_obj);
|
||||
/// node = new MotionNode { context_id=0, motion=0x41000003, jump_error_code=0 };
|
||||
/// pending_motions.append(node); // tail-splice, NO clear
|
||||
/// initted = 1;
|
||||
/// LeaveGround(this);
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's real construction paths (<c>MovementManager::Create</c> +
|
||||
/// the lazy-create guard at every <c>MovementManager</c> entry point)
|
||||
/// call this exactly once (or twice on the lazy-create double-call —
|
||||
/// §6g, genuine retail, not "fixed") before exposing the interpreter to
|
||||
/// any caller. acdream's constructors default <see cref="Initted"/> to
|
||||
/// <c>true</c> directly (see that property's doc comment) rather than
|
||||
/// routing every construction through this method, so this port is the
|
||||
/// verbatim, fully-testable "reset to default/idle state" operation —
|
||||
/// callers that want retail's FULL reset semantics (state defaults +
|
||||
/// sentinel + LeaveGround tail), not just the <c>Initted</c> flag, call
|
||||
/// this explicitly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void EnterDefaultState()
|
||||
{
|
||||
RawState = new RawMotionState();
|
||||
InterpretedState = InterpretedMotionState.Default();
|
||||
|
||||
InitializeMotionTables?.Invoke();
|
||||
|
||||
AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0);
|
||||
|
||||
Initted = true;
|
||||
|
||||
LeaveGround();
|
||||
}
|
||||
|
||||
// ── FUN_00528b70 / FUN_00528bb0 — set_hold_run / SetHoldKey ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::set_hold_run</c> (0x00528b70, decomp §5c @306053,
|
||||
/// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only
|
||||
/// acts when <paramref name="holdingRun"/> actually FLIPS the current
|
||||
/// effective hold-run state (skip redundant re-application).
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 306053-306070):
|
||||
/// <code>
|
||||
/// eax = (arg2 == 0); // "run key is up"
|
||||
/// edx = (raw_state.current_holdkey != Run);
|
||||
/// if (eax != edx) { // XOR: state actually changes
|
||||
/// raw_state.current_holdkey = (arg2 != 0) + 1; // false->1(None), true->2(Run)
|
||||
/// apply_current_movement(this, arg3, 0);
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="holdingRun">Retail <c>arg2</c> — nonzero = the run key
|
||||
/// is currently held down.</param>
|
||||
/// <param name="interrupt">Retail <c>arg3</c> — passed through as
|
||||
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
|
||||
public void set_hold_run(bool holdingRun, bool interrupt)
|
||||
{
|
||||
bool runKeyUp = !holdingRun;
|
||||
bool notCurrentlyRun = RawState.CurrentHoldKey != HoldKey.Run;
|
||||
|
||||
if (runKeyUp != notCurrentlyRun)
|
||||
{
|
||||
RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None;
|
||||
apply_current_movement(cancelMoveTo: interrupt, allowJump: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CMotionInterp::SetHoldKey</c> (0x00528bb0, decomp §5d @306072,
|
||||
/// FULL BODY, closes J13's SetHoldKey leg). No-op if
|
||||
/// <paramref name="key"/> already equals <see cref="RawMotionState.CurrentHoldKey"/>.
|
||||
/// Setting <see cref="HoldKey.None"/> only takes effect (and
|
||||
/// re-applies movement) if the CURRENT hold key is
|
||||
/// <see cref="HoldKey.Run"/> — requesting None while already something
|
||||
/// else (e.g. Invalid) is silently ignored. Setting
|
||||
/// <see cref="HoldKey.Run"/> takes effect whenever the current key
|
||||
/// isn't already Run.
|
||||
///
|
||||
/// <para>
|
||||
/// Verbatim (raw 306072-306095):
|
||||
/// <code>
|
||||
/// current = raw_state.current_holdkey;
|
||||
/// if (arg2 != current) {
|
||||
/// if (arg2 == HoldKey_None) {
|
||||
/// if (current == HoldKey_Run) {
|
||||
/// raw_state.current_holdkey = HoldKey_None;
|
||||
/// apply_current_movement(this, arg3, 0);
|
||||
/// }
|
||||
/// } else if (arg2 == HoldKey_Run && current != HoldKey_Run) {
|
||||
/// raw_state.current_holdkey = HoldKey_Run;
|
||||
/// apply_current_movement(this, arg3, 0);
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This is the function <c>DoMotion</c> (W5) calls when bit
|
||||
/// <c>0x800</c> (SetHoldKey) of the incoming <c>MovementParameters</c>
|
||||
/// requests a hold-key change, and what <see cref="adjust_motion"/>
|
||||
/// reads back via <see cref="RawMotionState.CurrentHoldKey"/> (mirrored
|
||||
/// onto <see cref="CurrentHoldKey"/> — see the field doc) to decide
|
||||
/// whether <see cref="apply_run_to_command"/> fires.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="key">Retail <c>arg2</c>.</param>
|
||||
/// <param name="cancelMoveTo">Retail <c>arg3</c> — passed through as
|
||||
/// <c>apply_current_movement</c>'s <c>cancelMoveTo</c> arg.</param>
|
||||
public void SetHoldKey(HoldKey key, bool cancelMoveTo)
|
||||
{
|
||||
HoldKey current = RawState.CurrentHoldKey;
|
||||
if (key == current)
|
||||
return;
|
||||
|
||||
if (key == HoldKey.None)
|
||||
{
|
||||
if (current == HoldKey.Run)
|
||||
{
|
||||
RawState.CurrentHoldKey = HoldKey.None;
|
||||
apply_current_movement(cancelMoveTo, allowJump: false);
|
||||
}
|
||||
}
|
||||
else if (key == HoldKey.Run && current != HoldKey.Run)
|
||||
{
|
||||
RawState.CurrentHoldKey = HoldKey.Run;
|
||||
apply_current_movement(cancelMoveTo, allowJump: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
|
||||
|
|
@ -1990,6 +2525,25 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// every axis overwritten, defaults included), re-apply the whole
|
||||
/// movement through the sink, then replay fresh actions under the
|
||||
/// 15-bit stamp gate.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>R3-W4 (Adjacent finding, W0-pins.md):</b> raw 305946-305949
|
||||
/// computes <c>eax_2 = motion_allows_jump(this->interpreted_state.forward_command)</c>
|
||||
/// — the OLD forward command, BEFORE <c>copy_movement_from</c>
|
||||
/// overwrites <see cref="InterpretedState"/> two lines later — then
|
||||
/// calls <c>apply_current_movement(this, 1, allowJump)</c> where
|
||||
/// <c>allowJump = (eax_2 == 0)</c> (the raw's own
|
||||
/// <c>-((esi_1 - esi_1))</c> is decompiler noise for that same
|
||||
/// boolean). Ported here as <see cref="MotionAllowsJump"/> on the
|
||||
/// PRE-overwrite <see cref="InterpretedState"/>.ForwardCommand,
|
||||
/// snapshotted before the flat-copy below. Passed through to
|
||||
/// <see cref="ApplyInterpretedMovement"/> per its TODO(W5) — this
|
||||
/// funnel calls the interpreted body directly (not the
|
||||
/// <see cref="apply_current_movement"/> dual-dispatch gate), matching
|
||||
/// the existing S2 architecture: inbound funnel state is always
|
||||
/// server-authoritative, so it always wants the interpreted path
|
||||
/// regardless of IsThePlayer/autonomous.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null)
|
||||
{
|
||||
|
|
@ -1997,6 +2551,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
|
||||
RawState.CurrentStyle = ims.CurrentStyle;
|
||||
|
||||
// motion_allows_jump on the OLD forward_command, BEFORE the
|
||||
// flat-copy below overwrites it (Adjacent finding).
|
||||
bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None;
|
||||
|
||||
// copy_movement_from — flat overwrite, no per-field presence checks.
|
||||
InterpretedState.ForwardCommand = ims.ForwardCommand;
|
||||
InterpretedState.ForwardSpeed = ims.ForwardSpeed;
|
||||
|
|
@ -2005,7 +2563,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
InterpretedState.TurnCommand = ims.TurnCommand;
|
||||
InterpretedState.TurnSpeed = ims.TurnSpeed;
|
||||
|
||||
ApplyInterpretedMovement(ims.CurrentStyle, sink);
|
||||
ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);
|
||||
|
||||
if (ims.Actions is { } actions)
|
||||
{
|
||||
|
|
@ -2053,11 +2611,39 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// node's <c>jump_error_code</c> — since the stop succeeded, that value
|
||||
/// is <c>0</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>R3-W4 (closes J11's param-plumbing leg):</b> <paramref name="cancelMoveTo"/>/
|
||||
/// <paramref name="allowJump"/> are retail's <c>arg2</c>/<c>arg3</c> —
|
||||
/// threaded through NOW per the plan (every real caller already has an
|
||||
/// opinion: <see cref="apply_current_movement"/>'s dual-dispatch tail
|
||||
/// passes its own args through unchanged; <see cref="MoveToInterpretedState"/>
|
||||
/// passes <c>(true, motion_allows_jump(OLD forward_command) == 0)</c> per
|
||||
/// the Adjacent finding). Their ONLY retail consumer in this function is
|
||||
/// the garbled tail expression at raw 305778
|
||||
/// (<c>(((arg3&1)<<2)|(arg2&1))<<0xf</c> feeding an
|
||||
/// uninitialized-local byte test before conditionally calling
|
||||
/// <c>InterpretedMotionState::RemoveMotion(0x6500000d)</c> a SECOND time)
|
||||
/// — a BN x87/uninit-local artifact class, not a readable retail
|
||||
/// algorithm. TODO(W5): resolve that tail against a clean decompile (or
|
||||
/// cdb capture) once <c>MovementParameters</c> flows through this
|
||||
/// funnel end to end; until then the params are accepted and preserved
|
||||
/// for signature parity but do not change this function's control flow
|
||||
/// (matches the funnel's existing "no MovementParameters yet" posture,
|
||||
/// e.g. <see cref="DispatchInterpretedMotion"/>'s own TODO(W5)).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void ApplyInterpretedMovement(uint currentStyle, IInterpretedMotionSink? sink)
|
||||
public void ApplyInterpretedMovement(
|
||||
uint currentStyle, IInterpretedMotionSink? sink,
|
||||
bool cancelMoveTo = false, bool allowJump = false)
|
||||
{
|
||||
if (PhysicsObj is null) return;
|
||||
|
||||
// cancelMoveTo/allowJump: accepted for signature parity (see the
|
||||
// TODO(W5) above) — not yet consumed by this function's body.
|
||||
_ = cancelMoveTo;
|
||||
_ = allowJump;
|
||||
|
||||
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
|
||||
MyRunRate = InterpretedState.ForwardSpeed;
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,24 @@ public sealed class PhysicsBody
|
|||
/// </summary>
|
||||
public bool IsFullyConstrained { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4 — retail <c>CPhysicsObj::last_move_was_autonomous</c>, read by
|
||||
/// <c>CPhysicsObj::movement_is_autonomous</c> (0x0050eb30, decomp §7a
|
||||
/// @276443: <c>return this->last_move_was_autonomous;</c>). Gates the
|
||||
/// A3 dual-dispatch predicate in <c>MotionInterpreter.apply_current_movement</c>/
|
||||
/// <c>ReportExhaustion</c>/<c>SetWeenieObject</c>/<c>SetPhysicsObject</c>:
|
||||
/// true means the last motion on this body was locally-simulated
|
||||
/// (player input / local prediction), false means it was a
|
||||
/// server-driven dead-reckoning update. Set true at the local-player
|
||||
/// input chokepoint (App layer — <c>PlayerMovementController</c>);
|
||||
/// left false (the safe default — routes to
|
||||
/// <c>apply_interpreted_movement</c>) for DR-applied remote updates.
|
||||
/// <see cref="MotionInterpreter.LeaveGround"/> also sets this true
|
||||
/// itself: retail's <c>set_local_velocity(&var_c, 1)</c> call passes
|
||||
/// the autonomous flag literal <c>1</c> (raw @305763-305765).
|
||||
/// </summary>
|
||||
public bool LastMoveWasAutonomous { get; set; }
|
||||
|
||||
// ── convenience helpers ────────────────────────────────────────────────
|
||||
|
||||
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
|
||||
|
|
@ -324,10 +342,23 @@ public sealed class PhysicsBody
|
|||
/// worldY = col0.y*localX + col1.y*localY + col2.y*localZ
|
||||
/// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ
|
||||
/// We replicate this as a Quaternion rotation, which is equivalent.
|
||||
///
|
||||
/// <para>
|
||||
/// R3-W4: retail's <c>set_local_velocity</c> takes a second
|
||||
/// <c>autonomous</c> arg (<c>CPhysicsObj::set_local_velocity</c>,
|
||||
/// stores it to <see cref="LastMoveWasAutonomous"/> — read by
|
||||
/// <c>CPhysicsObj::movement_is_autonomous</c>, the A3 dual-dispatch
|
||||
/// predicate). Defaults to <c>false</c> to preserve every pre-W4 call
|
||||
/// site's behavior (server/interpreted-driven callers never asserted
|
||||
/// autonomy); <see cref="MotionInterpreter.LeaveGround"/> is the one
|
||||
/// caller that passes <c>true</c> (raw @305763-305765,
|
||||
/// <c>set_local_velocity(&var_c, 1)</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void set_local_velocity(Vector3 localVelocity)
|
||||
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
|
||||
{
|
||||
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
|
||||
LastMoveWasAutonomous = autonomous;
|
||||
set_velocity(worldVelocity);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -335,12 +335,20 @@ public sealed class AnimationSequencerCutoverTraceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void S8_KFix18_FallingSkipsLink()
|
||||
public void S8_LeaveGroundLinkStrip_FallingEngagesInstantly()
|
||||
{
|
||||
// EXPECTED-DIFF(R3-W4): the K-fix18 skipTransitionLink flag is
|
||||
// DELETED. The instant-Falling engage is retail's own mechanism:
|
||||
// MotionInterpreter.LeaveGround (0x00528b00) fires the
|
||||
// RemoveLinkAnimations seam — bound to this sequencer's
|
||||
// RemoveAllLinkAnimations (CPhysicsObj::RemoveLinkAnimations
|
||||
// 0x0050fe20 → CSequence::remove_all_link_animations) — after the
|
||||
// Falling dispatch. Same final state the flag produced.
|
||||
var seq = NewSeq(out var loader);
|
||||
seq.SetCycle(NC, Ready);
|
||||
seq.SetCycle(NC, Walk, 1.0f);
|
||||
seq.SetCycle(NC, Falling, 1.0f, skipTransitionLink: true);
|
||||
seq.SetCycle(NC, Falling, 1.0f);
|
||||
seq.RemoveAllLinkAnimations(); // = LeaveGround's seam firing
|
||||
Assert.Equal(
|
||||
"10C@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=40000015 mod=1.00",
|
||||
Describe(seq, loader));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,784 @@
|
|||
using System.Linq;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MotionInterpreterGroundLifecycleTests — R3-W4 (closes J8, J10, J11-shape,
|
||||
// J12, J13; J18 one-liner rides along).
|
||||
//
|
||||
// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md
|
||||
// §4 (HitGround 0x00528ac0 @305996, LeaveGround 0x00528b00 @306022,
|
||||
// ReportExhaustion 0x005288d0 @305861, enter_default_state 0x00528c80
|
||||
// @306124) + §5c/5d (set_hold_run 0x00528b70 @306053, SetHoldKey 0x00528bb0
|
||||
// @306072) + raw acclient_2013_pseudo_c.txt:305838-305932 (apply_current_movement
|
||||
// 0x00528870, SetWeenieObject 0x00528920, SetPhysicsObject 0x00528970) +
|
||||
// W0-pins.md A3 (dual-dispatch gate) / A8 (enter_default_state no-clear).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Fake WeenieObject — IsThePlayer/IsCreature independently
|
||||
/// configurable, for the A3 dispatch truth table.</summary>
|
||||
file sealed class FakeWeenie : IWeenieObject
|
||||
{
|
||||
public bool IsThePlayerResult;
|
||||
public bool IsCreatureResult = true;
|
||||
|
||||
public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; }
|
||||
public bool InqRunRate(out float rate) { rate = 1f; return true; }
|
||||
public bool CanJump(float extent) => true;
|
||||
public bool IsThePlayer() => IsThePlayerResult;
|
||||
public bool IsCreature() => IsCreatureResult;
|
||||
}
|
||||
|
||||
public sealed class MotionInterpreterGroundLifecycleTests
|
||||
{
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static PhysicsBody MakeGrounded()
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||
};
|
||||
body.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active;
|
||||
return body;
|
||||
}
|
||||
|
||||
private static PhysicsBody MakeAirborne()
|
||||
{
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||
};
|
||||
body.TransientState = TransientStateFlags.Active;
|
||||
return body;
|
||||
}
|
||||
|
||||
private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null)
|
||||
{
|
||||
body ??= MakeGrounded();
|
||||
return new MotionInterpreter(body, weenie);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HitGround — 0x00528ac0 @305996
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void HitGround_NoPhysicsObj_NoOp()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.HitGround();
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitGround_NonCreatureWeenie_NoOp()
|
||||
{
|
||||
// raw 305720-305724: (weenie_obj == 0 || eax_2 != 0) -- a WEENIE
|
||||
// present whose IsCreature() returns false skips the whole body.
|
||||
var weenie = new FakeWeenie { IsCreatureResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.HitGround();
|
||||
|
||||
Assert.False(called, "a non-creature weenie must skip HitGround's body entirely");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitGround_NoWeenie_Proceeds()
|
||||
{
|
||||
// weenie_obj == 0 -> the OR short-circuits true, body proceeds.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.HitGround();
|
||||
|
||||
Assert.True(called, "no weenie_obj must still proceed (weenie==0 half of the OR)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitGround_GravityFlagClear_NoOp()
|
||||
{
|
||||
// raw 305726-305730: state bit 0x400 (Gravity) gates the body.
|
||||
var body = MakeGrounded();
|
||||
body.State &= ~PhysicsStateFlags.Gravity;
|
||||
var interp = MakeInterp(body);
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.HitGround();
|
||||
|
||||
Assert.False(called, "HitGround must no-op when the Gravity state flag is clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitGround_CreatureWithGravity_CallsRemoveLinkAnimationsThenReapplies()
|
||||
{
|
||||
var weenie = new FakeWeenie { IsCreatureResult = true };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.HitGround();
|
||||
|
||||
Assert.True(called, "HitGround must call the RemoveLinkAnimations seam");
|
||||
// apply_current_movement(false, true) re-syncs velocity from the
|
||||
// current interpreted state (grounded write, AP-75-adjacent).
|
||||
Assert.True(body.Velocity.Length() > 0f, "HitGround must re-apply current movement");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LeaveGround — 0x00528b00 @306022
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_NoPhysicsObj_NoOp()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_NonCreatureWeenie_NoOp()
|
||||
{
|
||||
var weenie = new FakeWeenie { IsCreatureResult = false };
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.StandingLongJump = true;
|
||||
interp.JumpExtent = 0.7f;
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.False(called);
|
||||
// Nothing in the body ran -- StandingLongJump/JumpExtent untouched.
|
||||
Assert.True(interp.StandingLongJump);
|
||||
Assert.Equal(0.7f, interp.JumpExtent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_GravityFlagClear_NoOp()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
body.State &= ~PhysicsStateFlags.Gravity;
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = true;
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.False(called);
|
||||
Assert.True(interp.StandingLongJump);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_CreatureWithGravity_SetsVelocityAndResetsJumpState()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = true;
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.False(interp.StandingLongJump, "standing_longjump = 0 on leave-ground");
|
||||
Assert.Equal(0f, interp.JumpExtent, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_CallsRemoveLinkAnimationsThenReapplies()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
bool called = false;
|
||||
interp.RemoveLinkAnimations = () => called = true;
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.True(called, "LeaveGround must call the RemoveLinkAnimations seam");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_VelocityCarriesGetLeaveGroundVelocity_WithNonzeroJumpExtent()
|
||||
{
|
||||
// A6/A5: get_leave_ground_velocity composes get_state_velocity() +
|
||||
// z=GetJumpVZ(). With JumpExtent set and a weenie present, the body
|
||||
// local velocity written must match GetLeaveGroundVelocity()'s Z.
|
||||
//
|
||||
// Real call-site precondition: by the time LeaveGround fires, the
|
||||
// body has ALREADY left the ground (jump() calls set_on_walkable(false)
|
||||
// before LeaveGround runs; a ledge walk-off similarly clears OnWalkable
|
||||
// via the physics resolver BEFORE this callback). With OnWalkable
|
||||
// false, LeaveGround's own tail apply_current_movement(0,0) call
|
||||
// takes the "airborne -- preserve integrated velocity" branch in the
|
||||
// AP-77 adaptation (ApplyCurrentMovementInterpreted), so the Z this
|
||||
// call just set is not immediately clobbered by a resync.
|
||||
var weenie = new AcDream.Core.Physics.PlayerWeenie();
|
||||
var body = MakeGrounded();
|
||||
body.set_on_walkable(false);
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.JumpExtent = 0.5f;
|
||||
|
||||
var expected = interp.GetLeaveGroundVelocity();
|
||||
interp.LeaveGround();
|
||||
|
||||
// set_local_velocity transforms local->world via Orientation
|
||||
// (Identity in these fixtures), so world == local here.
|
||||
Assert.Equal(expected.Z, body.Velocity.Z, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveGround_SetLocalVelocity_UsesAutonomousFlag()
|
||||
{
|
||||
// raw 305763-305765: CPhysicsObj::set_local_velocity(&var_c, 1) --
|
||||
// the autonomous=1 arg. PhysicsBody.LastMoveWasAutonomous should
|
||||
// reflect this after LeaveGround runs (movement_is_autonomous, A3's
|
||||
// apply_current_movement dispatch reads this same flag).
|
||||
var body = MakeGrounded();
|
||||
body.LastMoveWasAutonomous = false;
|
||||
var interp = MakeInterp(body);
|
||||
|
||||
interp.LeaveGround();
|
||||
|
||||
Assert.True(body.LastMoveWasAutonomous, "LeaveGround's set_local_velocity call carries autonomous=1");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// enter_default_state — 0x00528c80 @306124 (A8: append sentinel, NO drain)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void EnterDefaultState_AppendsReadySentinel_WithoutDrainingExistingNodes()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.AddToQueue(1, MotionCommand.RunForward, 0);
|
||||
interp.AddToQueue(2, MotionCommand.WalkForward, 0x48);
|
||||
|
||||
interp.EnterDefaultState();
|
||||
|
||||
var nodes = interp.PendingMotions.ToArray();
|
||||
Assert.Equal(3, nodes.Length);
|
||||
Assert.Equal(new MotionNode(1, MotionCommand.RunForward, 0), nodes[0]);
|
||||
Assert.Equal(new MotionNode(2, MotionCommand.WalkForward, 0x48), nodes[1]);
|
||||
Assert.Equal(new MotionNode(0, MotionCommand.Ready, 0), nodes[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterDefaultState_SetsInitted()
|
||||
{
|
||||
var interp = new MotionInterpreter { PhysicsObj = MakeGrounded() };
|
||||
// Force Initted false to prove EnterDefaultState is what flips it
|
||||
// (the ctor already defaults it true -- see the Initted-gating
|
||||
// section below for why).
|
||||
interp.Initted = false;
|
||||
|
||||
interp.EnterDefaultState();
|
||||
|
||||
Assert.True(interp.Initted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterDefaultState_ResetsRawAndInterpretedStateToCtorDefaults()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.RawState.ForwardSpeed = 2.94f;
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Run;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.TurnCommand = MotionCommand.TurnRight;
|
||||
|
||||
interp.EnterDefaultState();
|
||||
|
||||
Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand);
|
||||
Assert.Equal(1.0f, interp.RawState.ForwardSpeed);
|
||||
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||
Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand);
|
||||
Assert.Equal(0u, interp.InterpretedState.TurnCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterDefaultState_CallsInitializeMotionTablesSeam()
|
||||
{
|
||||
var interp = MakeInterp();
|
||||
bool called = false;
|
||||
interp.InitializeMotionTables = () => called = true;
|
||||
|
||||
interp.EnterDefaultState();
|
||||
|
||||
Assert.True(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnterDefaultState_TailCallsLeaveGround()
|
||||
{
|
||||
// enter_default_state's LAST step is an unconditional LeaveGround()
|
||||
// call (raw @306153). With a grounded creature+gravity body and a
|
||||
// nonzero JumpExtent pre-seeded, LeaveGround's reset (standing_longjump=0,
|
||||
// jump_extent=0) must be observable after EnterDefaultState.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.StandingLongJump = true;
|
||||
interp.JumpExtent = 0.9f;
|
||||
|
||||
interp.EnterDefaultState();
|
||||
|
||||
Assert.False(interp.StandingLongJump, "EnterDefaultState's LeaveGround tail must fire");
|
||||
Assert.Equal(0f, interp.JumpExtent, precision: 5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initted gating — apply_current_movement / ReportExhaustion (A3 entry gate)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ApplyCurrentMovement_NotInitted_NoOp()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.Initted = false;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
|
||||
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCurrentMovement_Initted_Proceeds()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
Assert.True(interp.Initted, "the two-arg constructor defaults Initted=true (see final report)");
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
|
||||
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
Assert.True(body.Velocity.Length() > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportExhaustion_NotInitted_NoOp()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.Initted = false;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
|
||||
interp.ReportExhaustion();
|
||||
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportExhaustion_NoPhysicsObj_NoOp()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
// No exception is the assertion -- retail's entry gate is
|
||||
// physics_obj != 0 && initted != 0, both ANDed.
|
||||
interp.ReportExhaustion();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Dual-dispatch truth table — A3: IsThePlayer && movement_is_autonomous
|
||||
// gates apply_raw_movement vs apply_interpreted_movement, for
|
||||
// apply_current_movement AND ReportExhaustion.
|
||||
//
|
||||
// Reading raw_movement's effect: apply_raw_movement copies RawState ->
|
||||
// InterpretedState (see apply_raw_movement's existing doc comment) then
|
||||
// re-normalizes via adjust_motion. apply_interpreted_movement instead
|
||||
// reads InterpretedState directly and pushes velocity via
|
||||
// get_state_velocity/set_local_velocity when grounded. We distinguish
|
||||
// the two dispatch targets by seeding RawState and InterpretedState with
|
||||
// DIFFERENT forward commands/speeds and observing which one drove the
|
||||
// resulting body velocity.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, /* expectRaw */ true)] // IsThePlayer && autonomous -> raw
|
||||
[InlineData(true, false, /* expectRaw */ false)] // IsThePlayer but not autonomous -> interpreted
|
||||
[InlineData(false, true, /* expectRaw */ false)] // autonomous but NOT the player -> interpreted (remote player IS a creature but not "the player")
|
||||
[InlineData(false, false, /* expectRaw */ false)] // neither -> interpreted
|
||||
public void ApplyCurrentMovement_DualDispatch_MatchesA3TruthTable(
|
||||
bool isThePlayer, bool autonomous, bool expectRaw)
|
||||
{
|
||||
var weenie = new FakeWeenie { IsThePlayerResult = isThePlayer, IsCreatureResult = true };
|
||||
var body = MakeGrounded();
|
||||
body.LastMoveWasAutonomous = autonomous;
|
||||
var interp = MakeInterp(body, weenie);
|
||||
|
||||
// RawState drives WalkForward (speed 1 => WalkAnimSpeed); InterpretedState
|
||||
// drives RunForward (speed 1 => RunAnimSpeed). WalkAnimSpeed != RunAnimSpeed
|
||||
// so the resulting body.Velocity.Y distinguishes which path ran.
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.RawState.ForwardSpeed = 1.0f;
|
||||
interp.RawState.ForwardHoldKey = HoldKey.None;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
float expectedY = expectRaw
|
||||
? MotionInterpreter.WalkAnimSpeed // apply_raw_movement re-derives from RawState
|
||||
: MotionInterpreter.RunAnimSpeed; // apply_interpreted_movement reads InterpretedState as-is
|
||||
Assert.Equal(expectedY, body.Velocity.Y, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCurrentMovement_NoWeenie_Autonomous_DispatchesRaw()
|
||||
{
|
||||
// weenie_obj == 0 short-circuits the OR to true (raw 305849:
|
||||
// weenie_obj == 0 || eax_2 != 0) -- no weenie means "treat as the
|
||||
// player" for dispatch purposes.
|
||||
var body = MakeGrounded();
|
||||
body.LastMoveWasAutonomous = true;
|
||||
var interp = MakeInterp(body); // no weenie
|
||||
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.RawState.ForwardSpeed = 1.0f;
|
||||
interp.RawState.ForwardHoldKey = HoldKey.None;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportExhaustion_DualDispatch_PassesZeroZeroArgs()
|
||||
{
|
||||
// A3: ReportExhaustion's dispatch args are hardcoded (0, 0) --
|
||||
// distinct from apply_current_movement which passes its own
|
||||
// (cancelMoveTo, allowJump) through. We can't observe the args
|
||||
// directly (both paths are void), so this proves ReportExhaustion
|
||||
// dispatches at all when initted+player+autonomous (raw path) by
|
||||
// observing the same velocity-divergence trick.
|
||||
var weenie = new FakeWeenie { IsThePlayerResult = true };
|
||||
var body = MakeGrounded();
|
||||
body.LastMoveWasAutonomous = true;
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.RawState.ForwardSpeed = 1.0f;
|
||||
interp.RawState.ForwardHoldKey = HoldKey.None;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.ReportExhaustion();
|
||||
|
||||
Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportExhaustion_RemotePlayer_DispatchesInterpreted()
|
||||
{
|
||||
// The ACE-divergence pin: a remote player weenie (IsThePlayer=false,
|
||||
// IsCreature=true) must route INTERPRETED, not raw -- even though
|
||||
// it IS a creature. ACE's IsCreature-gated read would wrongly send
|
||||
// this down apply_raw_movement.
|
||||
var weenie = new FakeWeenie { IsThePlayerResult = false, IsCreatureResult = true };
|
||||
var body = MakeGrounded();
|
||||
body.LastMoveWasAutonomous = true; // even with autonomous=true...
|
||||
var interp = MakeInterp(body, weenie);
|
||||
interp.RawState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.RawState.ForwardSpeed = 1.0f;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.ReportExhaustion();
|
||||
|
||||
// ...IsThePlayer==false forces the interpreted path.
|
||||
Assert.Equal(MotionInterpreter.RunAnimSpeed, body.Velocity.Y, precision: 2);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SetWeenieObject / SetPhysicsObject — 0x00528920 / 0x00528970 re-apply
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetWeenieObject_WhilePhysicsBoundAndInitted_ReappliesMovement()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = new MotionInterpreter { PhysicsObj = body, Initted = true };
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.SetWeenieObject(new FakeWeenie { IsThePlayerResult = false });
|
||||
|
||||
Assert.True(body.Velocity.Length() > 0f, "SetWeenieObject must re-apply movement when bound+initted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeenieObject_NoPhysicsObj_DoesNotReapply()
|
||||
{
|
||||
var interp = new MotionInterpreter();
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
|
||||
// Must not throw despite no PhysicsObj.
|
||||
interp.SetWeenieObject(new FakeWeenie());
|
||||
|
||||
Assert.Null(interp.PhysicsObj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWeenieObject_NotInitted_DoesNotReapply()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = new MotionInterpreter { PhysicsObj = body, Initted = false };
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.SetWeenieObject(new FakeWeenie());
|
||||
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPhysicsObject_BindsAndReappliesWhenInitted()
|
||||
{
|
||||
var interp = new MotionInterpreter { Initted = true };
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
var body = MakeGrounded();
|
||||
|
||||
interp.SetPhysicsObject(body);
|
||||
|
||||
Assert.Same(body, interp.PhysicsObj);
|
||||
Assert.True(body.Velocity.Length() > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPhysicsObject_NullArg_DoesNotReapply()
|
||||
{
|
||||
var interp = new MotionInterpreter { Initted = true };
|
||||
|
||||
// arg2 != 0 gates the reapply -- passing null must not throw and
|
||||
// must not attempt InterpretedState velocity work (no body to write to).
|
||||
interp.SetPhysicsObject(null);
|
||||
|
||||
Assert.Null(interp.PhysicsObj);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// set_hold_run — 0x00528b70 @306053 (XOR toggle guard)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHoldRun_TogglesFromNoneToRun_WhenHoldingRunKey()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
|
||||
interp.set_hold_run(holdingRun: true, interrupt: false);
|
||||
|
||||
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldRun_TogglesFromRunToNone_WhenReleasingRunKey()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Run;
|
||||
|
||||
interp.set_hold_run(holdingRun: false, interrupt: false);
|
||||
|
||||
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldRun_NoChange_WhenAlreadyInRequestedState_IsANoOp()
|
||||
{
|
||||
// XOR guard: eax(=arg2==0) != edx(=current!=Run) is FALSE when
|
||||
// arg2 requests exactly the state we're already in -- skip.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Run;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
interp.set_hold_run(holdingRun: true, interrupt: false);
|
||||
|
||||
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||
// No re-apply fired (still zero) -- the guard skipped the whole body.
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldRun_OnChange_CallsApplyCurrentMovementWithInterruptArg()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.set_hold_run(holdingRun: true, interrupt: true);
|
||||
|
||||
// apply_current_movement(arg3, 0) fired -- observable via the
|
||||
// grounded velocity re-application (AP-75-adjacent write).
|
||||
Assert.True(body.Velocity.Length() > 0f);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SetHoldKey — 0x00528bb0 @306072 (None-only-meaningful-from-Run)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_None_FromRun_TakesEffect()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Run;
|
||||
|
||||
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
|
||||
|
||||
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_None_FromInvalid_IsIgnored()
|
||||
{
|
||||
// raw @306072: setting None only takes effect from Run. Any other
|
||||
// starting state (Invalid, or already None) is silently ignored.
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Invalid;
|
||||
|
||||
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
|
||||
|
||||
Assert.Equal(HoldKey.Invalid, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_AlreadyNone_IsNoOp()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
|
||||
interp.SetHoldKey(HoldKey.None, cancelMoveTo: false);
|
||||
|
||||
Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_Run_FromNone_TakesEffect()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
|
||||
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
|
||||
|
||||
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_Run_AlreadyRun_IsNoOp()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.Run;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.RunForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
|
||||
|
||||
Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey);
|
||||
Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetHoldKey_EffectiveChange_ReappliesMovement()
|
||||
{
|
||||
var body = MakeGrounded();
|
||||
var interp = MakeInterp(body);
|
||||
interp.RawState.CurrentHoldKey = HoldKey.None;
|
||||
interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward;
|
||||
interp.InterpretedState.ForwardSpeed = 1.0f;
|
||||
|
||||
interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false);
|
||||
|
||||
Assert.True(body.Velocity.Length() > 0f, "an effective SetHoldKey change must reapply movement");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// adjust_motion creature guard — J18 one-liner (retires register TS-34)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AdjustMotion_NonCreatureWeenie_SkipsNormalization()
|
||||
{
|
||||
var weenie = new FakeWeenie { IsCreatureResult = false };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
uint motion = MotionCommand.WalkBackward;
|
||||
float speed = 1.0f;
|
||||
|
||||
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
|
||||
|
||||
// Retail returns immediately for a non-creature weenie -- WalkBackward
|
||||
// must NOT be remapped to WalkForward/negated-speed.
|
||||
Assert.Equal(MotionCommand.WalkBackward, motion);
|
||||
Assert.Equal(1.0f, speed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustMotion_CreatureWeenie_NormalizesAsBefore()
|
||||
{
|
||||
var weenie = new FakeWeenie { IsCreatureResult = true };
|
||||
var interp = MakeInterp(weenie: weenie);
|
||||
uint motion = MotionCommand.WalkBackward;
|
||||
float speed = 1.0f;
|
||||
|
||||
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkForward, motion);
|
||||
Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustMotion_NoWeenie_StillNormalizes()
|
||||
{
|
||||
// weenie == null must behave like a creature (the "always creature
|
||||
// unless proven otherwise" retail idiom -- weenie_obj != 0 gates
|
||||
// the query at all).
|
||||
var interp = MakeInterp();
|
||||
uint motion = MotionCommand.WalkBackward;
|
||||
float speed = 1.0f;
|
||||
|
||||
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
|
||||
|
||||
Assert.Equal(MotionCommand.WalkForward, motion);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue