feat(L.2g-S2b): wire remote entities onto the CMotionInterp funnel (DEV-1 integration)
OnLiveMotionUpdated's remote branch (368 lines of bulk-copy + cycle picker + per-axis DoInterpretedMotion + command-list router) collapses to: build InboundInterpretedState from the wire (retail UnPack defaults; MoveTo packets feed the PlanMoveToStart seed as the forward command) -> MotionInterpreter.MoveToInterpretedState(ims, RemoteMotionSink) -> sink.Commit(). RemoteMotionSink (new, App): receives the funnel's gate-passed dispatches in retail order; the axis-priority pick (fwd > side > turn), Run->Walk->Ready missing-cycle fallback, overlay routing, ObservedOmega seeding, and diag lines are MOVED VERBATIM from the pre-S2 block. Register row AP-73 documents the single-cycle composition approximation (retail blends modifiers via re_modify — DEV-9, retires with S3/S6). Retail-verbatim behavior changes: - Absent stance now defaults to NonCombat 0x8000003D (retail UnPack default, S0-trace-verified) instead of keep-current. - Remote command lists flow through the funnel's 15-bit action-stamp gate — retail's actual mechanism for ACE's re-bundled stale entries; the old skip-SubState router workaround is now local-player-only. - Airborne cycle preservation is the funnel's contact_allows_move gate (K-fix17's guard semantics, now from the retail mechanism). - Stops ride the same path: empty UM -> flat copy -> Ready dispatch -> get_state_velocity 0 (DEV-3 core; the 300ms stop-detection fallback stays until S6 verifies NPC unification). Full suite green (3290). Live smoke next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7b0cbbda2c
commit
67506ce988
3 changed files with 289 additions and 315 deletions
|
|
@ -99,7 +99,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 68 rows
|
## 3. Documented approximation (AP) — 69 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -176,6 +176,8 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
| AP-73 | **Remote motion sink maps retail's substate+modifier animation model onto ONE cycle** — the L.2g S2 funnel dispatches retail-verbatim (style/forward/sidestep/turn per `apply_interpreted_movement` order + action-stamp gate), but `RemoteMotionSink.Commit` resolves the axes into a single `SetCycle` (forward locomotion > sidestep > turn priority + Run→Walk→Ready missing-cycle fallback); retail BLENDS modifiers over the substate cycle via `CMotionTable::re_modify` (DEV-9) | `src/AcDream.App/Rendering/RemoteMotionSink.cs` (`Commit`) | The pick + fallback are the pre-S2 behavior moved verbatim (user-verified across L.1/L.3); the STATE side is now fully retail through the funnel, so only the visible-animation composition approximates. Retire with the S3/S6 modifier port (`re_modify` + `subtract_motion` omega/velocity combine) | Run-while-turning shows pure run legs (no lean/turn blend); sidestep-while-walking shows one cycle only — animation-composition mismatch vs retail observers, position unaffected | `CMotionTable::re_modify` (GetObjectSequence tail, pc:298636); `StopSequenceMotion` modifier branch pc:298954 |
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 33 rows
|
## 4. Temporary stopgap (TS) — 33 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable
|
||||||
/// remote gets the same treatment as the local player.
|
/// remote gets the same treatment as the local player.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class RemoteMotion
|
internal sealed class RemoteMotion // internal: RemoteMotionSink (L.2g S2b) consumes it
|
||||||
{
|
{
|
||||||
public AcDream.Core.Physics.PhysicsBody Body;
|
public AcDream.Core.Physics.PhysicsBody Body;
|
||||||
public AcDream.Core.Physics.MotionInterpreter Motion;
|
public AcDream.Core.Physics.MotionInterpreter Motion;
|
||||||
|
|
@ -4521,83 +4521,32 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
|
// ── L.2g S2b (2026-07-02): remote entities flow through the
|
||||||
bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
|
// verbatim CMotionInterp funnel. The wire state becomes an
|
||||||
or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
|
// InboundInterpretedState (retail UnPack defaults for absent
|
||||||
or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
|
// fields — a flags=0 UM is a wholesale stop, S0-verified),
|
||||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
// MotionInterpreter.MoveToInterpretedState applies it with
|
||||||
&& rmCheck.Airborne;
|
// retail's exact dispatch order + the 15-bit action-stamp
|
||||||
|
// gate (conformance: RetailObserverTraceConformanceTests,
|
||||||
// Retail MotionTable::GetObjectSequence routes action-class
|
// 183/183 vs the live retail-observer cdb trace), and
|
||||||
// ForwardCommand values (creature attacks, chat-emotes) through
|
// RemoteMotionSink maps the gate-passed dispatches onto the
|
||||||
// the Action branch, where the swing is appended before the
|
// sequencer (axis-priority pick + missing-cycle fallback
|
||||||
// current cyclic tail and currState.Substate remains Ready.
|
// moved verbatim from the pre-S2 block; airborne handling is
|
||||||
// Treating 0x10000051/52/53 as SetCycle commands made the
|
// the funnel's contact_allows_move gate — the retail
|
||||||
// immediate follow-up Ready packet abort the swing.
|
// mechanism behind the old K-fix17 guard).
|
||||||
// Phase L.1c followup (2026-04-28): the next two state-update
|
|
||||||
// blocks are LIFTED out of the substate-only `else` branch so
|
|
||||||
// they run for BOTH overlay (Action/Modifier/ChatEmote) and
|
|
||||||
// substate (Walk/Run/Ready/etc) packets. Two separate research
|
|
||||||
// agents converged on the same root cause for the user-
|
|
||||||
// observed "creature just runs instead of attacking" symptom:
|
|
||||||
//
|
//
|
||||||
// 1. Attack swings arrive as mt=0 with
|
// MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as
|
||||||
// ForwardCommand=AttackHigh1 (Action class). Retail's
|
// the funnel's forward command (fullMotion/speedMod computed
|
||||||
// CMotionInterp::move_to_interpreted_state
|
// above) and additionally capture the path for the per-tick
|
||||||
// (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
|
// remote driver.
|
||||||
// forward_command from the wire into the body's
|
|
||||||
// InterpretedState UNCONDITIONALLY. With
|
|
||||||
// forward_command=AttackHigh1, get_state_velocity
|
|
||||||
// returns 0 because its gate is RunForward||WalkForward
|
|
||||||
// — body stops moving forward.
|
|
||||||
//
|
|
||||||
// 2. The acdream overlay branch was routing through
|
|
||||||
// PlayAction (animation overlay) but skipping ALL of:
|
|
||||||
// - ServerMoveToActive flag update
|
|
||||||
// - MoveToPath capture
|
|
||||||
// - InterpretedState.ForwardCommand assignment
|
|
||||||
// So during a swing UM, the body's InterpretedState
|
|
||||||
// stayed at RunForward from the prior MoveTo packet,
|
|
||||||
// ServerMoveToActive stayed true, and the per-tick
|
|
||||||
// remote driver kept steering + applying RunForward
|
|
||||||
// velocity through every frame.
|
|
||||||
//
|
|
||||||
// Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
|
|
||||||
// here because the latter is a heuristic that ONLY handles
|
|
||||||
// WalkForward / RunForward / WalkBackward / SideStep / Turn
|
|
||||||
// / Ready (MotionInterpreter.cs:941-970). For an Action
|
|
||||||
// command (e.g. AttackHigh1 = 0x10000062) the switch falls
|
|
||||||
// through and InterpretedState is silently NOT updated —
|
|
||||||
// exactly the bug we are fixing. Direct field assignment
|
|
||||||
// matches retail's <c>copy_movement_from</c> bulk-copy
|
|
||||||
// (acclient_2013_pseudo_c.txt:293301-293311).
|
|
||||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||||
{
|
{
|
||||||
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
|
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
|
||||||
|
|
||||||
// Bulk-copy the wire's resolved ForwardCommand + speed
|
// [FWD_WIRE] + observed-velocity history invalidation on
|
||||||
// into InterpretedState UNCONDITIONALLY (overlay,
|
// a forward-command change (pre-S2 behavior, unchanged:
|
||||||
// substate, AND MoveTo packets). Matches retail's
|
// the per-tick scaling must not reuse a stale ratio
|
||||||
// copy_movement_from semantics
|
// derived from the OLD motion).
|
||||||
// (acclient_2013_pseudo_c.txt:293301-293311) which does
|
|
||||||
// not filter by MovementType.
|
|
||||||
//
|
|
||||||
// For MoveTo packets, fullMotion is the RunForward seed
|
|
||||||
// from PlanMoveToStart, so this populates
|
|
||||||
// ForwardCommand=RunForward + ForwardSpeed=speed*runRate
|
|
||||||
// — what the OLD substate-only DoInterpretedMotion call
|
|
||||||
// (commit f794832 removed) used to set. Without it,
|
|
||||||
// apply_current_movement reads the default
|
|
||||||
// ForwardCommand=Ready and produces zero velocity, so
|
|
||||||
// chasing creatures only translate via UpdatePosition
|
|
||||||
// hard-snaps and at spawn appear posed at default
|
|
||||||
// (visible as "torso on the ground" until the first UP
|
|
||||||
// snap hits).
|
|
||||||
//
|
|
||||||
// For overlay (Action) packets this sets ForwardCommand
|
|
||||||
// to the Attack/Twitch/etc command, and
|
|
||||||
// get_state_velocity returns 0 because the gate is
|
|
||||||
// RunForward||WalkForward — body stops moving forward.
|
|
||||||
if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
|
if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
|
||||||
{
|
{
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
|
|
@ -4608,35 +4557,16 @@ public sealed class GameWindow : IDisposable
|
||||||
+ $"newCmd=0x{fullMotion:X8} "
|
+ $"newCmd=0x{fullMotion:X8} "
|
||||||
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
|
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
|
||||||
}
|
}
|
||||||
// Motion command changed — invalidate observed-velocity
|
|
||||||
// history so the per-tick scaling in TickAnimations
|
|
||||||
// doesn't reuse a stale ratio derived from the OLD
|
|
||||||
// motion (e.g. carrying run-pace serverSpeed into the
|
|
||||||
// first walk frame, which would briefly accelerate
|
|
||||||
// walk to run pace before settling).
|
|
||||||
remoteMot.PrevServerPosTime = 0.0;
|
remoteMot.PrevServerPosTime = 0.0;
|
||||||
}
|
}
|
||||||
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
|
|
||||||
// Pass speedMod through verbatim — preserve sign so retail's
|
|
||||||
// adjust_motion'd backward walk (cmd=WalkForward, spd<0)
|
|
||||||
// produces backward velocity in get_state_velocity, NOT
|
|
||||||
// forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped
|
|
||||||
// negative to 1.0 and made the dead-reckoned body translate
|
|
||||||
// forward despite the reverse-playback animation — visually
|
|
||||||
// "still walking forward" from the observer's POV.
|
|
||||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod;
|
|
||||||
|
|
||||||
if (update.MotionState.IsServerControlledMoveTo
|
if (update.MotionState.IsServerControlledMoveTo
|
||||||
&& update.MotionState.MoveToPath is { } path)
|
&& update.MotionState.MoveToPath is { } path)
|
||||||
{
|
{
|
||||||
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
||||||
.OriginToWorld(
|
.OriginToWorld(
|
||||||
path.OriginCellId,
|
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||||||
path.OriginX,
|
_liveCenterX, _liveCenterY);
|
||||||
path.OriginY,
|
|
||||||
path.OriginZ,
|
|
||||||
_liveCenterX,
|
|
||||||
_liveCenterY);
|
|
||||||
remoteMot.MoveToMinDistance = path.MinDistance;
|
remoteMot.MoveToMinDistance = path.MinDistance;
|
||||||
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
|
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
|
||||||
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
|
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
|
||||||
|
|
@ -4646,247 +4576,77 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else if (!update.MotionState.IsServerControlledMoveTo)
|
else if (!update.MotionState.IsServerControlledMoveTo)
|
||||||
{
|
{
|
||||||
// Off MoveTo — clear stale destination so the per-tick
|
// Off MoveTo — clear stale destination so the
|
||||||
// driver doesn't keep steering.
|
// per-tick driver doesn't keep steering.
|
||||||
remoteMot.HasMoveToDestination = false;
|
remoteMot.HasMoveToDestination = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (forwardIsOverlay)
|
// Build the funnel input. fullMotion/speedMod already
|
||||||
{
|
// encode retail's absent-field forward defaults (null/0
|
||||||
if (!remoteIsAirborne)
|
// → Ready; MoveTo → PlanMoveToStart seed). Style:
|
||||||
|
// retail's InterpretedMotionState::UnPack (0x0051f400)
|
||||||
|
// defaults an absent stance to NonCombat 0x8000003D —
|
||||||
|
// NOT keep-current (S0 trace: every empty UM applied
|
||||||
|
// 0x8000003d on the retail observer).
|
||||||
|
var ims = new AcDream.Core.Physics.InboundInterpretedState
|
||||||
{
|
{
|
||||||
AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
|
CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du,
|
||||||
ae.Sequencer,
|
ForwardCommand = fullMotion,
|
||||||
fullStyle,
|
ForwardSpeed = speedMod,
|
||||||
fullMotion,
|
SideStepCommand = 0u,
|
||||||
speedMod <= 0f ? 1f : speedMod);
|
SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f,
|
||||||
}
|
TurnCommand = 0u,
|
||||||
}
|
TurnSpeed = update.MotionState.TurnSpeed ?? 1f,
|
||||||
else
|
};
|
||||||
{
|
|
||||||
// Pick which cycle to play on the sequencer. Priority:
|
|
||||||
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
|
||||||
// 2. Else sidestep cmd if active — legs strafe.
|
|
||||||
// 3. Else turn cmd if active — legs pivot.
|
|
||||||
// 4. Else Ready — idle.
|
|
||||||
//
|
|
||||||
// For forward+sidestep or forward+turn, the forward cycle
|
|
||||||
// wins at the anim layer; the sidestep/turn contribute via
|
|
||||||
// MotionInterpreter velocity/omega writes.
|
|
||||||
uint animCycle = fullMotion;
|
|
||||||
float animSpeed = speedMod;
|
|
||||||
uint fwdLow = fullMotion & 0xFFu;
|
|
||||||
bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */
|
|
||||||
|| fwdLow == 0x07 /* Run */;
|
|
||||||
if (!fwdIsRunWalk)
|
|
||||||
{
|
|
||||||
// Forward is Ready (or absent). Prefer sidestep cycle if present,
|
|
||||||
// else turn cycle, else Ready.
|
|
||||||
if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0)
|
|
||||||
{
|
|
||||||
uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver
|
|
||||||
.ReconstructFullCommand(sideForAnim);
|
|
||||||
if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim;
|
|
||||||
animCycle = sideFullForAnim;
|
|
||||||
animSpeed = update.MotionState.SideStepSpeed ?? 1f;
|
|
||||||
}
|
|
||||||
else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0)
|
|
||||||
{
|
|
||||||
uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver
|
|
||||||
.ReconstructFullCommand(turnForAnim);
|
|
||||||
if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim;
|
|
||||||
animCycle = turnFullForAnim;
|
|
||||||
// SIGNED — do NOT MathF.Abs. ACE encodes TurnLeft on the
|
|
||||||
// wire as (TurnCommand=TurnRight, TurnSpeed=NEGATIVE),
|
|
||||||
// mirroring retail's adjust_motion convention. The
|
|
||||||
// sequencer's negative-speed path (EnqueueMotionData
|
|
||||||
// multiplies MotionData.Omega by speedMod, the
|
|
||||||
// synthesize-omega fallback flips zomega via
|
|
||||||
// -(pi/2)*adjustedSpeed) only produces the correct
|
|
||||||
// CCW rotation when the sign is preserved here.
|
|
||||||
// Confirmed by live wire trace 2026-05-03: TurnLeft
|
|
||||||
// input arrives as turnCmd16=0x000D, speed=-1.500.
|
|
||||||
animSpeed = update.MotionState.TurnSpeed ?? 1f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// K-fix17 (2026-04-26): preserve the Falling cycle while
|
|
||||||
// the remote is airborne. ACE broadcasts UpdateMotion
|
|
||||||
// mid-arc when the player turns / runs — the previous
|
|
||||||
// SetCycle here swapped Falling → RunForward, breaking
|
|
||||||
// the visible jump animation. The arc still played out
|
|
||||||
// physics-wise (body went up/down), but the legs ran
|
|
||||||
// instead of folded. Skip the cycle swap when airborne;
|
|
||||||
// the InterpretedState updates below still fire so the
|
|
||||||
// body's velocity matches the new motion command, AND
|
|
||||||
// the post-resolve landing path restores the cycle to
|
|
||||||
// whatever the interpreted state says when the body
|
|
||||||
// lands.
|
|
||||||
if (!remoteIsAirborne)
|
|
||||||
{
|
|
||||||
// Fallback chain for missing cycles in the MotionTable.
|
|
||||||
// SetCycle unconditionally calls ClearCyclicTail() before
|
|
||||||
// looking up the cycle; if the cycle is absent, the body
|
|
||||||
// ends up with no cyclic tail at all and every part snaps
|
|
||||||
// to its setup-default offset — visible as "torso on the
|
|
||||||
// ground" because most creatures' setup-default puts all
|
|
||||||
// limbs at the torso origin.
|
|
||||||
//
|
|
||||||
// This is specifically a regression from commit 186a584
|
|
||||||
// (Phase L.1c port): pre-fix, MoveTo packets fell through
|
|
||||||
// to fullMotion=Ready (which always exists in every
|
|
||||||
// MotionTable). Post-fix, MoveTo packets seed
|
|
||||||
// fullMotion=RunForward, but some creatures (especially
|
|
||||||
// when stance=HandCombat) lack a (combat, RunForward)
|
|
||||||
// cycle. Fall through RunForward → WalkForward → Ready
|
|
||||||
// until we find one the table actually contains.
|
|
||||||
//
|
|
||||||
// Note: this fallback is for the SEQUENCER (visible
|
|
||||||
// animation) only. InterpretedState.ForwardCommand still
|
|
||||||
// gets the wire's (or seeded) ForwardCommand verbatim
|
|
||||||
// so apply_current_movement produces correct velocity.
|
|
||||||
uint cycleToPlay = animCycle;
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
|
||||||
&& (animCycle & 0xFFu) is 0x05u or 0x07u)
|
|
||||||
{
|
|
||||||
bool hc = ae.Sequencer.HasCycle(fullStyle, cycleToPlay);
|
|
||||||
System.Console.WriteLine(
|
|
||||||
$"[HASCYCLE] guid={update.Guid:X8} style=0x{fullStyle:X8} "
|
|
||||||
+ $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}");
|
|
||||||
}
|
|
||||||
if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
|
|
||||||
{
|
|
||||||
uint requested = cycleToPlay;
|
|
||||||
// RunForward (0x44000007) → WalkForward (0x45000005)
|
|
||||||
if ((cycleToPlay & 0xFFu) == 0x07
|
|
||||||
&& ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
|
|
||||||
{
|
|
||||||
cycleToPlay = 0x45000005u;
|
|
||||||
}
|
|
||||||
// WalkForward → Ready (0x41000003)
|
|
||||||
else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
|
|
||||||
{
|
|
||||||
cycleToPlay = 0x41000003u;
|
|
||||||
}
|
|
||||||
// Ready missing too — leave the existing cycle alone
|
|
||||||
// by not calling SetCycle at all (avoids the
|
|
||||||
// ClearCyclicTail wipe).
|
|
||||||
else
|
|
||||||
{
|
|
||||||
cycleToPlay = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
||||||
{
|
|
||||||
Console.WriteLine(
|
|
||||||
$"UM cycle missing for guid=0x{update.Guid:X8} " +
|
|
||||||
$"style=0x{fullStyle:X8} requested=0x{requested:X8} " +
|
|
||||||
$"→ fallback=0x{cycleToPlay:X8}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cycleToPlay != 0)
|
|
||||||
{
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
|
||||||
&& (ae.Sequencer.CurrentMotion != cycleToPlay
|
|
||||||
|| MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f))
|
|
||||||
{
|
|
||||||
System.Console.WriteLine(
|
|
||||||
$"[SETCYCLE] guid={update.Guid:X8} "
|
|
||||||
+ $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) "
|
|
||||||
+ $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})");
|
|
||||||
}
|
|
||||||
ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retail runs the full MotionInterp state machine on every
|
|
||||||
// remote. Route each wire command (forward, sidestep, turn)
|
|
||||||
// through DoInterpretedMotion so apply_current_movement →
|
|
||||||
// get_state_velocity → PhysicsBody.set_local_velocity fires
|
|
||||||
// on a subsequent tick exactly as retail's FUN_00529210
|
|
||||||
// (apply_current_movement) does.
|
|
||||||
//
|
|
||||||
// Decompile refs:
|
|
||||||
// FUN_00529930 DoMotion
|
|
||||||
// FUN_00528f70 DoInterpretedMotion
|
|
||||||
// FUN_00528960 get_state_velocity
|
|
||||||
// FUN_00529210 apply_current_movement
|
|
||||||
// ServerMoveToActive flag, MoveToPath capture, and the
|
|
||||||
// InterpretedState.ForwardCommand bulk-copy are already
|
|
||||||
// handled by the LIFTED block above (so overlay-class swings
|
|
||||||
// also clear stale MoveTo state and update the body's
|
|
||||||
// forward command). This branch only handles sidestep /
|
|
||||||
// turn axes plus the ObservedOmega seed — none of which
|
|
||||||
// appear on overlay packets, so the existing logic is
|
|
||||||
// correct unchanged. (`remoteMot` is the same dictionary
|
|
||||||
// entry obtained at the top of the lifted block.)
|
|
||||||
if (remoteMot is not null)
|
|
||||||
{
|
|
||||||
// Sidestep axis.
|
|
||||||
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
||||||
{
|
{
|
||||||
uint sideFull = AcDream.Core.Physics.MotionCommandResolver
|
uint sideFull = AcDream.Core.Physics.MotionCommandResolver
|
||||||
.ReconstructFullCommand(sideCmd16);
|
.ReconstructFullCommand(sideCmd16);
|
||||||
if (sideFull == 0) sideFull = 0x65000000u | sideCmd16;
|
ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16);
|
||||||
float sideSpd = update.MotionState.SideStepSpeed ?? 1f;
|
|
||||||
remoteMot.Motion.DoInterpretedMotion(
|
|
||||||
sideFull, sideSpd, modifyInterpretedState: true);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// No sidestep — clear any leftover strafing motion.
|
|
||||||
remoteMot.Motion.StopInterpretedMotion(
|
|
||||||
AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true);
|
|
||||||
remoteMot.Motion.StopInterpretedMotion(
|
|
||||||
AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn axis — and use as the on/off switch for ObservedOmega.
|
|
||||||
// On turn start: seed ObservedOmega from the formula
|
|
||||||
// (π/2 × turnSpeed) so rotation begins THIS tick without
|
|
||||||
// waiting for the next UP to observe a delta.
|
|
||||||
// On turn end: zero ObservedOmega so rotation stops
|
|
||||||
// immediately instead of coasting at the last observed
|
|
||||||
// rate until the next UP shows zero delta.
|
|
||||||
// UpdatePosition still REFINES the rate from actual
|
|
||||||
// server deltas (more accurate than the formula), but
|
|
||||||
// this ensures instant on/off response.
|
|
||||||
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
|
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
|
||||||
{
|
{
|
||||||
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
|
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
|
||||||
.ReconstructFullCommand(turnCmd16);
|
.ReconstructFullCommand(turnCmd16);
|
||||||
if (turnFull == 0) turnFull = 0x65000000u | turnCmd16;
|
ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16);
|
||||||
float turnSpd = update.MotionState.TurnSpeed ?? 1f;
|
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
{
|
{
|
||||||
System.Console.WriteLine(
|
System.Console.WriteLine(
|
||||||
$"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} "
|
$"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} "
|
||||||
+ $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} "
|
+ $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed:F3}");
|
||||||
+ $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) "
|
|
||||||
+ $"speed={turnSpd:F3}");
|
|
||||||
}
|
}
|
||||||
remoteMot.Motion.DoInterpretedMotion(
|
|
||||||
turnFull, turnSpd, modifyInterpretedState: true);
|
|
||||||
// Seed ObservedOmega with formula so rotation starts
|
|
||||||
// immediately; UP deltas will refine the rate.
|
|
||||||
uint turnLow = turnFull & 0xFFu;
|
|
||||||
if (turnLow == 0x0D /* TurnRight */)
|
|
||||||
remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd);
|
|
||||||
else if (turnLow == 0x0E /* TurnLeft */)
|
|
||||||
remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
// Command list → the funnel's action list. Retail applies
|
||||||
|
// EVERY entry via DoInterpretedMotion under the 15-bit
|
||||||
|
// server_action_stamp gate — the gate is what makes ACE's
|
||||||
|
// re-bundled stale entries (e.g. the Ready bundled into a
|
||||||
|
// RunForward UM, 2026-05-03 finding) inert, replacing the
|
||||||
|
// old skip-SubState-class workaround.
|
||||||
|
if (update.MotionState.Commands is { Count: > 0 } cmdList)
|
||||||
{
|
{
|
||||||
remoteMot.Motion.StopInterpretedMotion(
|
var actionList = new System.Collections.Generic.List<AcDream.Core.Physics.InboundMotionAction>(cmdList.Count);
|
||||||
AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true);
|
foreach (var item in cmdList)
|
||||||
remoteMot.Motion.StopInterpretedMotion(
|
{
|
||||||
AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true);
|
uint full = AcDream.Core.Physics.MotionCommandResolver
|
||||||
// Zero ObservedOmega immediately — don't coast.
|
.ReconstructFullCommand(item.Command);
|
||||||
remoteMot.ObservedOmega = System.Numerics.Vector3.Zero;
|
if (full == 0) full = 0x10000000u | item.Command;
|
||||||
|
actionList.Add(new AcDream.Core.Physics.InboundMotionAction(
|
||||||
|
full,
|
||||||
|
Stamp: item.PackedSequence & 0x7FFF,
|
||||||
|
Autonomous: (item.PackedSequence & 0x8000) != 0,
|
||||||
|
Speed: item.Speed));
|
||||||
|
}
|
||||||
|
ims.Actions = actionList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RemoteMotionSink? sink = ae.Sequencer is not null
|
||||||
|
? new RemoteMotionSink(ae.Sequencer, remoteMot, update.Guid)
|
||||||
|
: null;
|
||||||
|
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
||||||
|
sink?.Commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||||
|
|
@ -4938,7 +4698,11 @@ public sealed class GameWindow : IDisposable
|
||||||
// a second SetCycle call wiped the first within the same UM
|
// a second SetCycle call wiped the first within the same UM
|
||||||
// packet processing. Only Actions/Modifiers/ChatEmotes (overlays
|
// packet processing. Only Actions/Modifiers/ChatEmotes (overlays
|
||||||
// that interleave with the cycle) belong in the list iteration.
|
// that interleave with the cycle) belong in the list iteration.
|
||||||
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
if (update.Guid == _playerServerGuid // L.2g S2b: LOCAL ONLY — remote command
|
||||||
|
// lists flow through the funnel's
|
||||||
|
// action-stamp gate (retail's actual
|
||||||
|
// mechanism for bundled stale entries)
|
||||||
|
&& update.MotionState.Commands is { Count: > 0 } cmds)
|
||||||
{
|
{
|
||||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
{
|
{
|
||||||
|
|
@ -4964,7 +4728,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState)
|
if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState)
|
||||||
continue;
|
continue;
|
||||||
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
||||||
ae.Sequencer,
|
ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)`
|
||||||
fullStyle,
|
fullStyle,
|
||||||
item.Command,
|
item.Command,
|
||||||
item.Speed);
|
item.Speed);
|
||||||
|
|
|
||||||
208
src/AcDream.App/Rendering/RemoteMotionSink.cs
Normal file
208
src/AcDream.App/Rendering/RemoteMotionSink.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
using System;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L.2g S2b — the App-side <see cref="IInterpretedMotionSink"/> for remote
|
||||||
|
/// entities: receives the funnel's retail-ordered dispatches
|
||||||
|
/// (<c>CMotionInterp::apply_interpreted_movement</c> order: style →
|
||||||
|
/// forward-or-Falling → sidestep → turn → actions) and maps them onto the
|
||||||
|
/// single-cycle <see cref="AnimationSequencer"/>.
|
||||||
|
///
|
||||||
|
/// <para>The axis-priority pick (forward locomotion beats sidestep beats
|
||||||
|
/// turn), the missing-cycle fallback chain (Run→Walk→Ready), and the
|
||||||
|
/// overlay routing for action-class commands are MOVED VERBATIM from the
|
||||||
|
/// pre-S2 <c>GameWindow.OnLiveMotionUpdated</c> block — they are acdream's
|
||||||
|
/// documented approximation of retail's substate+modifier model
|
||||||
|
/// (GetObjectSequence + re_modify, deviation DEV-9) pending the S3/S6
|
||||||
|
/// modifier port. What changed is upstream: the STATE now flows through
|
||||||
|
/// the verbatim CMotionInterp funnel instead of ad-hoc bulk copies.</para>
|
||||||
|
///
|
||||||
|
/// <para>Airborne behavior needs no special-casing here: the funnel's
|
||||||
|
/// verbatim <c>contact_allows_move</c> gate (0x00528240) blocks
|
||||||
|
/// style/locomotion/action dispatches for a gravity-bound creature without
|
||||||
|
/// ground contact — retail's real mechanism behind the old K-fix17
|
||||||
|
/// "preserve the Falling cycle while airborne" guard.</para>
|
||||||
|
///
|
||||||
|
/// Lifecycle: construct per-UM, pass to
|
||||||
|
/// <see cref="MotionInterpreter.MoveToInterpretedState"/>, then call
|
||||||
|
/// <see cref="Commit"/> once to resolve the collected axes into at most one
|
||||||
|
/// SetCycle.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RemoteMotionSink : IInterpretedMotionSink
|
||||||
|
{
|
||||||
|
private readonly AnimationSequencer _sequencer;
|
||||||
|
private readonly GameWindow.RemoteMotion _remote;
|
||||||
|
private readonly uint _guid;
|
||||||
|
|
||||||
|
private uint _style; // last style-class dispatch (0x8xxxxxxx)
|
||||||
|
private uint _substate; // first substate-class dispatch (0x4xxxxxxx) — fwd wins by dispatch order
|
||||||
|
private float _substateSpeed = 1f;
|
||||||
|
private uint _sidestep; // modifier-class sidestep (0x6500000F/0x10)
|
||||||
|
private float _sidestepSpeed = 1f;
|
||||||
|
private uint _turn; // modifier-class turn (0x6500000D/0x0E)
|
||||||
|
private float _turnSpeed = 1f;
|
||||||
|
|
||||||
|
public RemoteMotionSink(AnimationSequencer sequencer, GameWindow.RemoteMotion remote, uint guid)
|
||||||
|
{
|
||||||
|
_sequencer = sequencer;
|
||||||
|
_remote = remote;
|
||||||
|
_guid = guid;
|
||||||
|
_style = sequencer.CurrentStyle != 0 ? sequencer.CurrentStyle : 0x8000003Du;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyMotion(uint motion, float speed)
|
||||||
|
{
|
||||||
|
// Style class (stance): retail dispatches it first each apply pass.
|
||||||
|
if ((motion & 0x80000000u) != 0 && (motion & 0x10000000u) == 0)
|
||||||
|
{
|
||||||
|
_style = motion;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var route = AnimationCommandRouter.Classify(motion);
|
||||||
|
if (route is AnimationCommandRouteKind.Action
|
||||||
|
or AnimationCommandRouteKind.Modifier
|
||||||
|
or AnimationCommandRouteKind.ChatEmote)
|
||||||
|
{
|
||||||
|
// Overlay (attack swings, emotes): append over the current
|
||||||
|
// cyclic tail immediately — the substate cycle is unaffected
|
||||||
|
// (retail Action branch of GetObjectSequence). The funnel's
|
||||||
|
// contact gate already filtered airborne cases.
|
||||||
|
AnimationCommandRouter.RouteFullCommand(
|
||||||
|
_sequencer, _style, motion, speed <= 0f ? 1f : speed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint low = motion & 0xFFu;
|
||||||
|
bool isTurn = low is 0x0D or 0x0E && (motion & 0xFF000000u) == 0x65000000u;
|
||||||
|
bool isSidestep = low is 0x0F or 0x10 && (motion & 0xFF000000u) == 0x65000000u;
|
||||||
|
|
||||||
|
if (isTurn)
|
||||||
|
{
|
||||||
|
_turn = motion;
|
||||||
|
_turnSpeed = speed;
|
||||||
|
// Seed ObservedOmega from the wire turn so rotation starts THIS
|
||||||
|
// tick; UpdatePosition orientation snaps correct any drift.
|
||||||
|
// TurnLeft arrives as TurnRight + negative speed (adjust_motion
|
||||||
|
// convention) or as the explicit 0x0E command.
|
||||||
|
float signed = low == 0x0E ? -MathF.Abs(speed) : speed;
|
||||||
|
_remote.ObservedOmega = new System.Numerics.Vector3(
|
||||||
|
0, 0, -(MathF.PI / 2f) * signed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSidestep)
|
||||||
|
{
|
||||||
|
_sidestep = motion;
|
||||||
|
_sidestepSpeed = speed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substate class (Walk/Run/Ready/Falling/door On-Off/...): the
|
||||||
|
// FIRST one wins — the funnel dispatches forward before modifiers,
|
||||||
|
// so forward locomotion naturally outranks the rest.
|
||||||
|
if (_substate == 0)
|
||||||
|
{
|
||||||
|
_substate = motion;
|
||||||
|
_substateSpeed = speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopMotion(uint motion)
|
||||||
|
{
|
||||||
|
uint low = motion & 0xFFu;
|
||||||
|
if (low is 0x0D or 0x0E)
|
||||||
|
{
|
||||||
|
// Turn cleared — stop rotating immediately, don't coast.
|
||||||
|
_remote.ObservedOmega = System.Numerics.Vector3.Zero;
|
||||||
|
}
|
||||||
|
// Sidestep/turn STATE was already cleared by the funnel's flat
|
||||||
|
// copy_movement_from; nothing else to do.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the collected axes into the sequencer cycle. Priority
|
||||||
|
/// (pre-S2 picker, moved verbatim): forward locomotion → sidestep →
|
||||||
|
/// turn → whatever substate arrived (Ready included). Missing-cycle
|
||||||
|
/// fallback Run→Walk→Ready avoids SetCycle's unconditional
|
||||||
|
/// ClearCyclicTail on a cycle the MotionTable lacks ("torso on the
|
||||||
|
/// ground").
|
||||||
|
/// </summary>
|
||||||
|
public void Commit()
|
||||||
|
{
|
||||||
|
uint animCycle;
|
||||||
|
float animSpeed;
|
||||||
|
|
||||||
|
uint subLow = _substate & 0xFFu;
|
||||||
|
bool fwdIsRunWalk = subLow is 0x05 or 0x06 or 0x07;
|
||||||
|
|
||||||
|
if (fwdIsRunWalk || (_sidestep == 0 && _turn == 0))
|
||||||
|
{
|
||||||
|
if (_substate == 0) return; // nothing dispatched (airborne UM)
|
||||||
|
animCycle = _substate;
|
||||||
|
animSpeed = _substateSpeed;
|
||||||
|
}
|
||||||
|
else if (_sidestep != 0)
|
||||||
|
{
|
||||||
|
animCycle = _sidestep;
|
||||||
|
animSpeed = _sidestepSpeed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
animCycle = _turn;
|
||||||
|
animSpeed = _turnSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing-cycle fallback chain (moved verbatim; see the pre-S2
|
||||||
|
// comment block: regression 186a584, creatures lacking a
|
||||||
|
// (stance, RunForward) cycle).
|
||||||
|
uint cycleToPlay = animCycle;
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||||
|
&& (animCycle & 0xFFu) is 0x05u or 0x07u)
|
||||||
|
{
|
||||||
|
bool hc = _sequencer.HasCycle(_style, cycleToPlay);
|
||||||
|
System.Console.WriteLine(
|
||||||
|
$"[HASCYCLE] guid={_guid:X8} style=0x{_style:X8} "
|
||||||
|
+ $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}");
|
||||||
|
}
|
||||||
|
if (!_sequencer.HasCycle(_style, cycleToPlay))
|
||||||
|
{
|
||||||
|
uint requested = cycleToPlay;
|
||||||
|
if ((cycleToPlay & 0xFFu) == 0x07
|
||||||
|
&& _sequencer.HasCycle(_style, 0x45000005u))
|
||||||
|
{
|
||||||
|
cycleToPlay = 0x45000005u; // RunForward → WalkForward
|
||||||
|
}
|
||||||
|
else if (_sequencer.HasCycle(_style, 0x41000003u))
|
||||||
|
{
|
||||||
|
cycleToPlay = 0x41000003u; // → Ready
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cycleToPlay = 0; // leave the existing cycle alone
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"UM cycle missing for guid=0x{_guid:X8} " +
|
||||||
|
$"style=0x{_style:X8} requested=0x{requested:X8} " +
|
||||||
|
$"→ fallback=0x{cycleToPlay:X8}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycleToPlay == 0) return;
|
||||||
|
|
||||||
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||||
|
&& (_sequencer.CurrentMotion != cycleToPlay
|
||||||
|
|| MathF.Abs(_sequencer.CurrentSpeedMod - animSpeed) > 1e-3f))
|
||||||
|
{
|
||||||
|
System.Console.WriteLine(
|
||||||
|
$"[SETCYCLE] guid={_guid:X8} "
|
||||||
|
+ $"old=(motion=0x{_sequencer.CurrentMotion:X8} speed={_sequencer.CurrentSpeedMod:F3}) "
|
||||||
|
+ $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})");
|
||||||
|
}
|
||||||
|
_sequencer.SetCycle(_style, cycleToPlay, animSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue