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:
parent
8ec0944a1c
commit
9958458318
6 changed files with 275 additions and 135 deletions
|
|
@ -496,6 +496,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
|
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
|
||||||
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
||||||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||||
|
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,10 @@ include dungeons.
|
||||||
- **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact
|
- **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact
|
||||||
equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
|
equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
|
||||||
CreateObject/ParentEvent child attachment and per-frame hand-follow rendering.
|
CreateObject/ParentEvent child attachment and per-frame hand-follow rendering.
|
||||||
|
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
||||||
|
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
||||||
|
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
||||||
|
drives the player's motion table; direct command-list replay is removed.
|
||||||
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
||||||
|
|
||||||
**Freeze on landing:**
|
**Freeze on landing:**
|
||||||
|
|
|
||||||
81
docs/research/2026-07-11-local-combat-motion-pseudocode.md
Normal file
81
docs/research/2026-07-11-local-combat-motion-pseudocode.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Local combat UpdateMotion — retail receive pseudocode
|
||||||
|
|
||||||
|
Date: 2026-07-11
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Who chooses and plays the local player's melee or missile attack motion?
|
||||||
|
|
||||||
|
## Named-retail oracle
|
||||||
|
|
||||||
|
- `ClientCombatSystem::ExecuteAttack` at `0x0056BB70` sends the targeted
|
||||||
|
melee/missile request and updates combat-repeat state. It does not choose a
|
||||||
|
concrete swing command and does not play an animation locally.
|
||||||
|
- `CPhysics::SetObjectMovement` at `0x00509690` rejects a local autonomous
|
||||||
|
echo, but accepts a newer non-autonomous movement event, stores
|
||||||
|
`last_move_was_autonomous = false`, calls `unpack_movement`, then returns 1
|
||||||
|
so `CommandInterpreter::LoseControlToServer` runs.
|
||||||
|
- `MovementManager::unpack_movement` at `0x00524440`, case 0, unpacks an
|
||||||
|
`InterpretedMotionState`, calls `move_to_interpreted_state`, then applies the
|
||||||
|
sticky-object and standing-long-jump trailers.
|
||||||
|
- `CMotionInterp::move_to_interpreted_state` at `0x005289C0` copies the state
|
||||||
|
wholesale, reapplies style/forward/side/turn, and dispatches fresh command
|
||||||
|
list entries under the 15-bit `server_action_stamp` gate. The player skips
|
||||||
|
only command entries whose autonomous bit is set.
|
||||||
|
|
||||||
|
## ACE cross-reference
|
||||||
|
|
||||||
|
ACE's `Player_Melee.DoSwingMotion` selects the actual maneuver from the combat
|
||||||
|
table, constructs a `Motion`, marks the target sticky, and broadcasts it.
|
||||||
|
Missile attacks similarly enqueue the server-selected aim action, followed by
|
||||||
|
reload and Ready. `MovementData.IsAutonomous` documents the wire split as
|
||||||
|
client-initiated `true`, server-initiated `false`.
|
||||||
|
|
||||||
|
## Retail pseudocode
|
||||||
|
|
||||||
|
```text
|
||||||
|
on local attack input:
|
||||||
|
send TargetedMeleeAttack or TargetedMissileAttack
|
||||||
|
do not invent or play a swing locally
|
||||||
|
|
||||||
|
on UpdateMotion for local player:
|
||||||
|
apply movement and server-control sequence gates
|
||||||
|
if event.isAutonomous:
|
||||||
|
drop the entire event
|
||||||
|
|
||||||
|
player.lastMoveWasAutonomous = false
|
||||||
|
interrupt current move
|
||||||
|
unstick
|
||||||
|
apply outer style if it changed
|
||||||
|
|
||||||
|
if movementType is MoveTo/TurnTo:
|
||||||
|
route to MoveToManager
|
||||||
|
return
|
||||||
|
|
||||||
|
if movementType == InterpretedMotionState:
|
||||||
|
ims = constructor defaults (NonCombat, Ready, speeds 1)
|
||||||
|
unpack every present field over ims
|
||||||
|
move_to_interpreted_state(ims)
|
||||||
|
copy every axis wholesale
|
||||||
|
apply style, forward, sidestep, turn
|
||||||
|
for each fresh command-list entry:
|
||||||
|
if local player and entry.autonomous: skip
|
||||||
|
dispatch entry through the motion table
|
||||||
|
apply sticky target trailer
|
||||||
|
write standingLongJump, including false
|
||||||
|
|
||||||
|
command interpreter loses control to server
|
||||||
|
next local input edge takes autonomous control back
|
||||||
|
```
|
||||||
|
|
||||||
|
## acdream correction
|
||||||
|
|
||||||
|
The local branch already had the autonomous-event rejection and the
|
||||||
|
interrupt/style/MoveTo routing, but deliberately skipped the mt-0 wholesale
|
||||||
|
copy. It then replayed `Commands[]` directly through
|
||||||
|
`AnimationCommandRouter`, bypassing retail's action-stamp and local-autonomous
|
||||||
|
gates. The correction routes local mt-0 through the existing
|
||||||
|
`MotionInterpreter.MoveToInterpretedState` and persistent motion-table sink,
|
||||||
|
exactly like remotes, and removes the direct replay. A shared App-layer factory
|
||||||
|
now owns nullable-wire-to-constructor-default conversion for both branches.
|
||||||
|
|
||||||
89
src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs
Normal file
89
src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the nullable wire fields from an mt-0 UpdateMotion payload into
|
||||||
|
/// retail's wholesale <see cref="InboundInterpretedState"/> value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Retail constructs an <c>InterpretedMotionState</c>, whose constructor
|
||||||
|
/// supplies NonCombat/Ready/1.0 defaults, then calls <c>UnPack</c> over it
|
||||||
|
/// before <c>MovementManager::move_to_interpreted_state</c> copies every
|
||||||
|
/// field. See <c>MovementManager::unpack_movement</c> at 0x00524440 and
|
||||||
|
/// <c>CMotionInterp::move_to_interpreted_state</c> at 0x005289c0.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Keeping this conversion outside <c>GameWindow</c> gives local and remote
|
||||||
|
/// actors one definition of absent-field semantics and one command-list
|
||||||
|
/// reconstruction path.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class InboundInterpretedMotionFactory
|
||||||
|
{
|
||||||
|
private const uint NonCombatStyle = 0x8000003Du;
|
||||||
|
private const uint Ready = 0x41000003u;
|
||||||
|
|
||||||
|
public static InboundInterpretedState Create(
|
||||||
|
in CreateObject.ServerMotionState wire,
|
||||||
|
uint fallbackForwardClass = 0x40000000u)
|
||||||
|
{
|
||||||
|
var result = new InboundInterpretedState
|
||||||
|
{
|
||||||
|
CurrentStyle = wire.Stance != 0
|
||||||
|
? 0x80000000u | wire.Stance
|
||||||
|
: NonCombatStyle,
|
||||||
|
ForwardCommand = ResolveForward(wire.ForwardCommand, fallbackForwardClass),
|
||||||
|
ForwardSpeed = wire.ForwardSpeed ?? 1f,
|
||||||
|
SideStepCommand = ResolveAxis(wire.SideStepCommand, 0x65000000u),
|
||||||
|
SideStepSpeed = wire.SideStepSpeed ?? 1f,
|
||||||
|
TurnCommand = ResolveAxis(wire.TurnCommand, 0x65000000u),
|
||||||
|
TurnSpeed = wire.TurnSpeed ?? 1f,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wire.Commands is { Count: > 0 } commands)
|
||||||
|
{
|
||||||
|
var actions = new List<InboundMotionAction>(commands.Count);
|
||||||
|
foreach (var item in commands)
|
||||||
|
{
|
||||||
|
uint command = MotionCommandResolver.ReconstructFullCommand(item.Command);
|
||||||
|
if (command == 0)
|
||||||
|
command = 0x10000000u | item.Command;
|
||||||
|
|
||||||
|
actions.Add(new InboundMotionAction(
|
||||||
|
command,
|
||||||
|
Stamp: item.PackedSequence & 0x7FFF,
|
||||||
|
Autonomous: (item.PackedSequence & 0x8000) != 0,
|
||||||
|
Speed: item.Speed));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Actions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ResolveForward(ushort? wireCommand, uint fallbackClass)
|
||||||
|
{
|
||||||
|
if (wireCommand is not { } command || command == 0)
|
||||||
|
return Ready;
|
||||||
|
|
||||||
|
uint resolved = MotionCommandResolver.ReconstructFullCommand(command);
|
||||||
|
if (resolved != 0)
|
||||||
|
return resolved;
|
||||||
|
|
||||||
|
uint commandClass = fallbackClass & 0xFF000000u;
|
||||||
|
return (commandClass != 0 ? commandClass : 0x40000000u) | command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ResolveAxis(ushort? wireCommand, uint fallbackClass)
|
||||||
|
{
|
||||||
|
if (wireCommand is not { } command || command == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
uint resolved = MotionCommandResolver.ReconstructFullCommand(command);
|
||||||
|
return resolved != 0 ? resolved : fallbackClass | command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4877,27 +4877,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// No-op if same; the sequencer's fast path guards against that.
|
// No-op if same; the sequencer's fast path guards against that.
|
||||||
uint priorMotion = ae.Sequencer.CurrentMotion;
|
uint priorMotion = ae.Sequencer.CurrentMotion;
|
||||||
|
|
||||||
// CRITICAL: for the local player, UpdatePlayerAnimation is the
|
// The SetObjectMovement gate above already rejects local
|
||||||
// authoritative driver of the sequencer. ACE's BroadcastMovement
|
// autonomous echoes. A local event reaching this point is
|
||||||
// echoes the player's own motion back, but:
|
// server-authored, so retail applies it through the interpreted
|
||||||
// (a) ACE's own ForwardSpeed is `creature.GetRunRate()`, which
|
// funnel. This is especially important for attacks: ACE chooses
|
||||||
// may differ from our locally-computed runRate (ACDREAM_RUN_SKILL
|
// the exact swing and carries it in Commands[].
|
||||||
// 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.
|
|
||||||
if (update.Guid == _playerServerGuid)
|
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
|
// B.6 slice 1 (2026-05-14): trace inbound motion for the
|
||||||
// local player. One line per inbound UM, gated on
|
// local player. One line per inbound UM, gated on
|
||||||
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
|
// 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
|
// already dropped the autonomous echoes that would have
|
||||||
// made this unsafe against ACE); then types 6-9 route to
|
// made this unsafe against ACE); then types 6-9 route to
|
||||||
// the player's MoveToManager. mt-0 falls through to the
|
// the player's MoveToManager. mt-0 falls through to the
|
||||||
// existing skip-sequencer posture (the interpreted-state
|
// interpreted-state copy below; LastMoveWasAutonomous=false
|
||||||
// copy + LoseControlToServer autonomy handoff is
|
// is the local equivalent of LoseControlToServer until the
|
||||||
// R5/MovementManager scope — V0-pins.md P1 adjacent seam).
|
// next user-input edge takes control back.
|
||||||
if (_playerController is not null)
|
if (_playerController is not null)
|
||||||
{
|
{
|
||||||
// P1 tail (00509730): the unpack path stores the wire
|
// P1 tail (00509730): the unpack path stores the wire
|
||||||
|
|
@ -4978,16 +4964,23 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// R5-V4: retail unpack_movement case-0 TAIL for the local
|
// Retail case-0 order: wholesale interpreted-state copy,
|
||||||
// player (@00524583-0052458e) — stick_to_object when the
|
// then stick_to_object, then the unconditional
|
||||||
// motionFlags 0x1 trailer carried a guid, then
|
// standing_longjump flag write (@00524551-0052458e).
|
||||||
// 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.
|
|
||||||
if (update.MotionState.MovementType == 0)
|
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
|
if (update.MotionState.StickyObjectGuid is { } playerSticky
|
||||||
&& playerSticky != 0)
|
&& playerSticky != 0)
|
||||||
StickToObjectFromWire(_playerHost, playerSticky);
|
StickToObjectFromWire(_playerHost, playerSticky);
|
||||||
|
|
@ -5084,34 +5077,13 @@ public sealed class GameWindow : IDisposable
|
||||||
remoteMot.PrevServerPosTime = 0.0;
|
remoteMot.PrevServerPosTime = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the funnel input. fullMotion/speedMod already
|
// Build the constructor-defaulted wholesale state through
|
||||||
// encode retail's absent-field forward defaults (null/0
|
// the SAME converter used by the local-player path.
|
||||||
// → Ready; mt 6-9 never reach here post-V4). Style:
|
var ims = AcDream.App.Physics.InboundInterpretedMotionFactory.Create(
|
||||||
// retail's InterpretedMotionState::UnPack (0x0051f400)
|
update.MotionState,
|
||||||
// defaults an absent stance to NonCombat 0x8000003D —
|
ae.Sequencer.CurrentMotion & 0xFF000000u);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
|
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")
|
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||||
{
|
{
|
||||||
System.Console.WriteLine(
|
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
|
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
|
||||||
// motion-table stack (GetObjectSequence + is_allowed
|
// motion-table stack (GetObjectSequence + is_allowed
|
||||||
// decide) — mt-0 only post-V4 (types 6-9 returned above).
|
// 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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class InboundInterpretedMotionFactoryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EmptyWireStateUsesRetailConstructorDefaults()
|
||||||
|
{
|
||||||
|
var wire = new CreateObject.ServerMotionState(0, null);
|
||||||
|
|
||||||
|
var result = InboundInterpretedMotionFactory.Create(wire);
|
||||||
|
|
||||||
|
Assert.Equal(0x8000003Du, result.CurrentStyle);
|
||||||
|
Assert.Equal(0x41000003u, result.ForwardCommand);
|
||||||
|
Assert.Equal(1f, result.ForwardSpeed);
|
||||||
|
Assert.Equal(0u, result.SideStepCommand);
|
||||||
|
Assert.Equal(1f, result.SideStepSpeed);
|
||||||
|
Assert.Equal(0u, result.TurnCommand);
|
||||||
|
Assert.Equal(1f, result.TurnSpeed);
|
||||||
|
Assert.Null(result.Actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttackCommandPreservesStampAutonomyAndSpeed()
|
||||||
|
{
|
||||||
|
var commands = new[]
|
||||||
|
{
|
||||||
|
new CreateObject.MotionItem(
|
||||||
|
Command: 0x0058, // ThrustMed
|
||||||
|
PackedSequence: 0x8007,
|
||||||
|
Speed: 1.75f),
|
||||||
|
};
|
||||||
|
var wire = new CreateObject.ServerMotionState(
|
||||||
|
Stance: 0x3E,
|
||||||
|
ForwardCommand: 0x0003,
|
||||||
|
Commands: commands);
|
||||||
|
|
||||||
|
var result = InboundInterpretedMotionFactory.Create(wire);
|
||||||
|
|
||||||
|
Assert.Equal(0x8000003Eu, result.CurrentStyle);
|
||||||
|
Assert.NotNull(result.Actions);
|
||||||
|
var action = Assert.Single(result.Actions);
|
||||||
|
Assert.Equal(0x10000058u, action.Command);
|
||||||
|
Assert.Equal(7, action.Stamp);
|
||||||
|
Assert.True(action.Autonomous);
|
||||||
|
Assert.Equal(1.75f, action.Speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AxesAndForwardCommandsUseSharedRuntimeCatalog()
|
||||||
|
{
|
||||||
|
var wire = new CreateObject.ServerMotionState(
|
||||||
|
Stance: 0x3F,
|
||||||
|
ForwardCommand: 0x0007,
|
||||||
|
ForwardSpeed: 2.85f,
|
||||||
|
SideStepCommand: 0x000F,
|
||||||
|
SideStepSpeed: -1.2f,
|
||||||
|
TurnCommand: 0x000D,
|
||||||
|
TurnSpeed: 1.5f);
|
||||||
|
|
||||||
|
var result = InboundInterpretedMotionFactory.Create(wire);
|
||||||
|
|
||||||
|
Assert.Equal(0x44000007u, result.ForwardCommand);
|
||||||
|
Assert.Equal(2.85f, result.ForwardSpeed);
|
||||||
|
Assert.Equal(0x6500000Fu, result.SideStepCommand);
|
||||||
|
Assert.Equal(-1.2f, result.SideStepSpeed);
|
||||||
|
Assert.Equal(0x6500000Du, result.TurnCommand);
|
||||||
|
Assert.Equal(1.5f, result.TurnSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue