From 67506ce9889cde355c02857bc97865305f7a3da9 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 18:59:00 +0200 Subject: [PATCH] feat(L.2g-S2b): wire remote entities onto the CMotionInterp funnel (DEV-1 integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 4 +- src/AcDream.App/Rendering/GameWindow.cs | 392 ++++-------------- src/AcDream.App/Rendering/RemoteMotionSink.cs | 208 ++++++++++ 3 files changed, 289 insertions(+), 315 deletions(-) create mode 100644 src/AcDream.App/Rendering/RemoteMotionSink.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 19de8c4e..0a3abfb4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 | |---|---|---|---|---|---| @@ -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 | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 68a5e2ce..6317f1a5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -408,7 +408,7 @@ public sealed class GameWindow : IDisposable /// remote gets the same treatment as the local player. /// /// - 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.MotionInterpreter Motion; @@ -4521,83 +4521,32 @@ public sealed class GameWindow : IDisposable } else { - var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion); - bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action - or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier - or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote; - bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck) - && rmCheck.Airborne; - - // Retail MotionTable::GetObjectSequence routes action-class - // ForwardCommand values (creature attacks, chat-emotes) through - // the Action branch, where the swing is appended before the - // current cyclic tail and currState.Substate remains Ready. - // Treating 0x10000051/52/53 as SetCycle commands made the - // immediate follow-up Ready packet abort the swing. - // 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: + // ── L.2g S2b (2026-07-02): remote entities flow through the + // verbatim CMotionInterp funnel. The wire state becomes an + // InboundInterpretedState (retail UnPack defaults for absent + // fields — a flags=0 UM is a wholesale stop, S0-verified), + // MotionInterpreter.MoveToInterpretedState applies it with + // retail's exact dispatch order + the 15-bit action-stamp + // gate (conformance: RetailObserverTraceConformanceTests, + // 183/183 vs the live retail-observer cdb trace), and + // RemoteMotionSink maps the gate-passed dispatches onto the + // sequencer (axis-priority pick + missing-cycle fallback + // moved verbatim from the pre-S2 block; airborne handling is + // the funnel's contact_allows_move gate — the retail + // mechanism behind the old K-fix17 guard). // - // 1. Attack swings arrive as mt=0 with - // ForwardCommand=AttackHigh1 (Action class). Retail's - // CMotionInterp::move_to_interpreted_state - // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies - // 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 copy_movement_from bulk-copy - // (acclient_2013_pseudo_c.txt:293301-293311). + // MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as + // the funnel's forward command (fullMotion/speedMod computed + // above) and additionally capture the path for the per-tick + // remote driver. if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; - // Bulk-copy the wire's resolved ForwardCommand + speed - // into InterpretedState UNCONDITIONALLY (overlay, - // substate, AND MoveTo packets). Matches retail's - // copy_movement_from semantics - // (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. + // [FWD_WIRE] + observed-velocity history invalidation on + // a forward-command change (pre-S2 behavior, unchanged: + // the per-tick scaling must not reuse a stale ratio + // derived from the OLD motion). if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) { if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") @@ -4608,35 +4557,16 @@ public sealed class GameWindow : IDisposable + $"newCmd=0x{fullMotion:X8} " + $"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.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 && update.MotionState.MoveToPath is { } path) { remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver .OriginToWorld( - path.OriginCellId, - path.OriginX, - path.OriginY, - path.OriginZ, - _liveCenterX, - _liveCenterY); + path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ, + _liveCenterX, _liveCenterY); remoteMot.MoveToMinDistance = path.MinDistance; remoteMot.MoveToDistanceToObject = path.DistanceToObject; remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; @@ -4646,247 +4576,77 @@ public sealed class GameWindow : IDisposable } else if (!update.MotionState.IsServerControlledMoveTo) { - // Off MoveTo — clear stale destination so the per-tick - // driver doesn't keep steering. + // Off MoveTo — clear stale destination so the + // per-tick driver doesn't keep steering. remoteMot.HasMoveToDestination = false; } - } - if (forwardIsOverlay) - { - if (!remoteIsAirborne) + // Build the funnel input. fullMotion/speedMod already + // encode retail's absent-field forward defaults (null/0 + // → 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( - ae.Sequencer, - fullStyle, - fullMotion, - speedMod <= 0f ? 1f : speedMod); - } - } - 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. + CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du, + ForwardCommand = fullMotion, + ForwardSpeed = speedMod, + SideStepCommand = 0u, + SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f, + TurnCommand = 0u, + TurnSpeed = update.MotionState.TurnSpeed ?? 1f, + }; if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { uint sideFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(sideCmd16); - if (sideFull == 0) sideFull = 0x65000000u | sideCmd16; - float sideSpd = update.MotionState.SideStepSpeed ?? 1f; - remoteMot.Motion.DoInterpretedMotion( - sideFull, sideSpd, modifyInterpretedState: true); + ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16); } - 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) { uint turnFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(turnCmd16); - if (turnFull == 0) turnFull = 0x65000000u | turnCmd16; - float turnSpd = update.MotionState.TurnSpeed ?? 1f; + ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16); if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} " - + $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} " - + $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) " - + $"speed={turnSpd:F3}"); + + $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed: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( - AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true); - remoteMot.Motion.StopInterpretedMotion( - AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true); - // Zero ObservedOmega immediately — don't coast. - remoteMot.ObservedOmega = System.Numerics.Vector3.Zero; + var actionList = new System.Collections.Generic.List(cmdList.Count); + foreach (var item in cmdList) + { + uint full = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(item.Command); + 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), // 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 // packet processing. Only Actions/Modifiers/ChatEmotes (overlays // 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") { @@ -4964,7 +4728,7 @@ public sealed class GameWindow : IDisposable if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState) continue; AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( - ae.Sequencer, + ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)` fullStyle, item.Command, item.Speed); diff --git a/src/AcDream.App/Rendering/RemoteMotionSink.cs b/src/AcDream.App/Rendering/RemoteMotionSink.cs new file mode 100644 index 00000000..1eb473ad --- /dev/null +++ b/src/AcDream.App/Rendering/RemoteMotionSink.cs @@ -0,0 +1,208 @@ +using System; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +/// +/// L.2g S2b — the App-side for remote +/// entities: receives the funnel's retail-ordered dispatches +/// (CMotionInterp::apply_interpreted_movement order: style → +/// forward-or-Falling → sidestep → turn → actions) and maps them onto the +/// single-cycle . +/// +/// 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 GameWindow.OnLiveMotionUpdated 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. +/// +/// Airborne behavior needs no special-casing here: the funnel's +/// verbatim contact_allows_move 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. +/// +/// Lifecycle: construct per-UM, pass to +/// , then call +/// once to resolve the collected axes into at most one +/// SetCycle. +/// +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. + } + + /// + /// 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"). + /// + 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); + } +}