fix(combat): apply server attack motions locally

Route accepted non-autonomous local UpdateMotion state through retail's wholesale interpreted-motion funnel, so ACE-selected melee and missile actions use the normal motion queue and action-stamp gate. Share nullable wire conversion with remotes and remove the local direct command replay.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 13:34:55 +02:00
parent 8ec0944a1c
commit 9958458318
6 changed files with 275 additions and 135 deletions

View file

@ -4877,27 +4877,13 @@ public sealed class GameWindow : IDisposable
// No-op if same; the sequencer's fast path guards against that.
uint priorMotion = ae.Sequencer.CurrentMotion;
// CRITICAL: for the local player, UpdatePlayerAnimation is the
// authoritative driver of the sequencer. ACE's BroadcastMovement
// echoes the player's own motion back, but:
// (a) ACE's own ForwardSpeed is `creature.GetRunRate()`, which
// may differ from our locally-computed runRate (ACDREAM_RUN_SKILL
// vs real server-side skills).
// (b) ACE omits the ForwardSpeed flag when speed == 1.0 (per
// InterpretedMotionState.BuildMovementFlags). When omitted,
// our wire parser returns null and we'd default to 1.0 —
// clobbering our locally-authoritative 2.375 × animScale
// and leaving the legs at 30 fps cadence regardless of
// actual run rate.
// So: for the player's own guid, skip the wire-echo SetCycle.
// UpdatePlayerAnimation has already set the correct cycle with
// our locally-chosen speedMod, and that value should persist
// until the next local motion change.
// The SetObjectMovement gate above already rejects local
// autonomous echoes. A local event reaching this point is
// server-authored, so retail applies it through the interpreted
// funnel. This is especially important for attacks: ACE chooses
// the exact swing and carries it in Commands[].
if (update.Guid == _playerServerGuid)
{
// Still update the stance echo (_playerMotionTableId, etc) via
// the paths above, but don't stomp the animation sequencer.
// B.6 slice 1 (2026-05-14): trace inbound motion for the
// local player. One line per inbound UM, gated on
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
@ -4927,9 +4913,9 @@ public sealed class GameWindow : IDisposable
// already dropped the autonomous echoes that would have
// made this unsafe against ACE); then types 6-9 route to
// the player's MoveToManager. mt-0 falls through to the
// existing skip-sequencer posture (the interpreted-state
// copy + LoseControlToServer autonomy handoff is
// R5/MovementManager scope — V0-pins.md P1 adjacent seam).
// interpreted-state copy below; LastMoveWasAutonomous=false
// is the local equivalent of LoseControlToServer until the
// next user-input edge takes control back.
if (_playerController is not null)
{
// P1 tail (00509730): the unpack path stores the wire
@ -4978,16 +4964,23 @@ public sealed class GameWindow : IDisposable
return;
}
// R5-V4: retail unpack_movement case-0 TAIL for the local
// player (@00524583-0052458e) — stick_to_object when the
// motionFlags 0x1 trailer carried a guid, then
// standing_longjump ← motionFlags 0x2 (UNCONDITIONAL —
// an absent flag clears it). The interpreted-state COPY
// stays skipped for the local player (the
// sequencer-authority posture above); these two writes
// are the only case-0 effects that apply here.
// Retail case-0 order: wholesale interpreted-state copy,
// then stick_to_object, then the unconditional
// standing_longjump flag write (@00524551-0052458e).
if (update.MotionState.MovementType == 0)
{
// Retail MovementManager::unpack_movement case 0
// (0x00524440) applies the constructor-defaulted wire
// state wholesale for the local player too. This is
// how ACE's server-selected combat action reaches the
// player's motion table; autonomous movement echoes
// were already rejected by the gate above.
var playerIms = AcDream.App.Physics.InboundInterpretedMotionFactory.Create(
update.MotionState,
ae.Sequencer.CurrentMotion & 0xFF000000u);
_playerController.Motion.MoveToInterpretedState(
playerIms, _playerController.Motion.DefaultSink);
if (update.MotionState.StickyObjectGuid is { } playerSticky
&& playerSticky != 0)
StickToObjectFromWire(_playerHost, playerSticky);
@ -5084,34 +5077,13 @@ public sealed class GameWindow : IDisposable
remoteMot.PrevServerPosTime = 0.0;
}
// Build the funnel input. fullMotion/speedMod already
// encode retail's absent-field forward defaults (null/0
// → Ready; mt 6-9 never reach here post-V4). 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
{
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);
ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16);
}
// Build the constructor-defaulted wholesale state through
// the SAME converter used by the local-player path.
var ims = AcDream.App.Physics.InboundInterpretedMotionFactory.Create(
update.MotionState,
ae.Sequencer.CurrentMotion & 0xFF000000u);
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
{
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(turnCmd16);
ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16);
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
@ -5120,29 +5092,6 @@ public sealed class GameWindow : IDisposable
}
}
// 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)
{
var actionList = new System.Collections.Generic.List<AcDream.Core.Physics.InboundMotionAction>(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;
}
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
// motion-table stack (GetObjectSequence + is_allowed
// decide) — mt-0 only post-V4 (types 6-9 returned above).
@ -5192,62 +5141,6 @@ public sealed class GameWindow : IDisposable
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
// Route command-list entries through the shared Core router.
// Retail/ACE send these as 16-bit MotionCommand lows in
// InterpretedMotionState.Commands[]; the router reconstructs the
// class byte and chooses PlayAction for actions/modifiers/emotes
// or SetCycle for persistent substates.
//
// 2026-05-03: SKIP SubState class commands (high-byte 0x40-0x4F).
// The animCycle picker above already chose the correct SubState
// cycle based on Forward/Sidestep/Turn command priority and just
// called SetCycle for it. Letting the Commands list also call
// SetCycle(SubState) would OVERRIDE our chosen cycle — e.g. ACE
// bundles Ready (0x41000003) into the Commands list of a
// RunForward UpdateMotion (cdb trace 2026-05-03 confirmed retail
// does the same), and our router would silently re-cycle the
// sequencer back to Ready right after we set RunForward. That's
// why observed retail-driven characters never visibly switched
// their leg cycle even though SETCYCLE diags fired correctly:
// 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.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")
{
var sb = new System.Text.StringBuilder();
sb.Append($"[CMD_LIST] guid={update.Guid:X8} fwd=0x{fullMotion:X8} cmds=[");
for (int i = 0; i < cmds.Count; i++)
{
if (i > 0) sb.Append(", ");
uint fc = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(cmds[i].Command);
var rt = AcDream.Core.Physics.AnimationCommandRouter.Classify(fc);
sb.Append($"0x{fc:X8}({rt})");
}
sb.Append("]");
System.Console.WriteLine(sb.ToString());
}
foreach (var item in cmds)
{
uint fullItemCommand = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(item.Command);
var itemRoute = AcDream.Core.Physics.AnimationCommandRouter
.Classify(fullItemCommand);
if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState)
continue;
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)`
fullStyle,
item.Command,
item.Speed);
}
}
return;
}