feat(retail): Phase B.6 — server-driven auto-walk done right
Closes #63, #69, #74, #75. Replaces the chain of Commit-B workarounds that compensated for ACE's MoveToChain getting cancelled by a leaked user-MoveToState packet during inbound auto-walk. The fix is architectural — auto-walk drives the body directly from the server-supplied path data, no player-input synthesis, no spurious wire-packet transitions, no grace-period band-aid. Architectural change (closes #75): PlayerMovementController.ApplyAutoWalkOverlay → DriveServerAutoWalk. - Steps Yaw toward target at retail-faithful turn rates. - Computes desired forward velocity from path runRate. - Calls _motion.DoMotion(WalkForward, speed) directly for the motion-interpreter state (drives animation cycle). - Sets _body.set_local_velocity directly when grounded. - Returns true to gate the user-input motion + velocity section in Update so user-input flow doesn't overwrite auto-walk velocity or motion state. Mirrors retail's MovementManager::PerformMovement case 6 (decomp 0x00524440) which never touches the user-input pipeline during server-controlled auto-walk. Wire-layer guard at GameWindow.cs:6419 retained as a SEMANTIC statement (`if (result.MotionStateChanged && !IsServerAutoWalking)`): user-MoveToState packets are for user-driven motion intent. During server-controlled auto-walk, the motion-state transitions caused by the animation override (RunForward / WalkForward / TurnLeft / TurnRight cycles) must not leak as user-cancellation packets. This is NOT the deleted 500ms grace-period band-aid; it's the wire-layer expressing the user-vs-server motion split. Animation plumbed for auto-walk phases (closes #69): - Moving forward → WalkForward (speed=1.0) / RunForward (speed=runRate) - Turn-first phase → TurnLeft / TurnRight (sign of yawStep) - Aligned-but-pre-step / arrival → no override (idle) Driven via _autoWalkMovingForwardThisFrame + _autoWalkTurnDirectionThisFrame fields set in DriveServerAutoWalk and read in the MovementResult construction at the bottom of Update. UpdatePlayerAnimation picks up the localAnimCmd as the highest-priority animation source. Walk/run threshold = 1.0m, retail-observed. ACE's wire-default of 15.0f is too generous; ACE's own physics layer uses 1.0f at MovementParameters.cs:50 (with the 15.0f line commented out) and Creature.cs:312 notes "default 15 distance seems too far". The formula matches retail's MovementParameters::get_command at decomp 0x0052aa00: running = (initialDist - distance_to_object) >= threshold, evaluated ONCE at chain start and held for the rest of the auto-walk (matches retail "runs all the way / walks all the way" behaviour). Wire-supplied threshold is ignored. Pickup gate (IsPickupableTarget) now uses BF_STUCK (acclient.h:6435, bit 0x4) to discriminate immovable scenery from real pickup items that share a Misc ItemType. Sign (pwd=0x14 with BF_STUCK) → blocked; spell component (pwd=0x10, no BF_STUCK) → allowed. ACE's PutItemInContainer (Player_Inventory.cs:831-836) responds with WeenieError.Stuck (0x29) on stuck items so the gate prevents wasted wire packets + a UX dead-end. R-key dispatch by target type. UseCurrentSelection's top-level IsUseableTarget gate was wrong (blocked USEABLE_NO=1 items that ARE pickupable). Reordered: 1. Creature → SendUse 2. Pickupable → SendPickUp 3. Useable → SendUse 4. Otherwise → "cannot be used" toast Each handler keeps its own gate. Matches retail's per-action server-side validation. AP cadence revert (closes #74). With the MoveToChain race fixed, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch ShouldSendPositionEvent gate (acclient_2013_pseudo_c.txt:700233-700285): Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed. Interval elapsed (>= 1 sec): send if cell or position frame changed. Adds _lastSentContactPlane field + ApproxPlaneEqual helper + PlayerMovementController.ContactPlane public accessor. Extended NotePositionSent(Vector3, uint, Plane, float) — both outbound sites (MoveToState + AP) pass _playerController.ContactPlane. Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne. CLAUDE.md updated with no-workarounds rule (commit `da126f9` on the worktree branch). Saved as feedback memory at memory/feedback_no_workarounds.md. Tests: build green; Core.Net 294/294; Core 1073/1081 (baseline, 8 pre-existing Physics failures unchanged). Visual-verified end-to-end on 2026-05-16 for far/near Use + PickUp on NPCs, doors, items, spell components, signs (correctly blocked), corpses, turn-first animation, run/walk thresholds, idle quiet, smooth- motion 1Hz. Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b5da17db76
commit
d640ed74e1
6 changed files with 1317 additions and 200 deletions
|
|
@ -6407,7 +6407,23 @@ public sealed class GameWindow : IDisposable
|
|||
var wireRot = YawToAcQuaternion(_playerController.Yaw);
|
||||
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
|
||||
|
||||
if (result.MotionStateChanged)
|
||||
// 2026-05-16 (issue #75): wire-layer semantic gate —
|
||||
// user-MoveToState packets are ONLY for user-initiated
|
||||
// motion intent. During server-controlled auto-walk
|
||||
// (inbound MoveToObject), motion-state transitions
|
||||
// come from the auto-walk's animation override, not
|
||||
// from user input. Sending a MoveToState in that case
|
||||
// would tell ACE "user took control" and cancel its
|
||||
// own MoveToChain. This is NOT a band-aid like the
|
||||
// earlier grace-period — it's the wire-layer's
|
||||
// expression of retail's architectural split between
|
||||
// user-input motion and server-driven motion: they
|
||||
// share the local motion-state machine but only
|
||||
// user-input flows back to the wire. Without the
|
||||
// refactor (issue #75) this guard masked a synthesis
|
||||
// leak; with the refactor it expresses the proper
|
||||
// semantic.
|
||||
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||
{
|
||||
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||
// Invalid = 0, None = 1, Run = 2
|
||||
|
|
@ -6446,11 +6462,13 @@ public sealed class GameWindow : IDisposable
|
|||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
|
||||
// last_sent_position_time + last_sent_position after each MTS send.
|
||||
// last_sent_position_time + last_sent_position + contact_plane
|
||||
// after each MTS send.
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
contactPlane: _playerController.ContactPlane,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
}
|
||||
|
||||
if (_playerController.HeartbeatDue)
|
||||
|
|
@ -6472,11 +6490,13 @@ public sealed class GameWindow : IDisposable
|
|||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
|
||||
// writing last_sent_position_time + last_sent_position after each AP.
|
||||
// writing last_sent_position_time + last_sent_position +
|
||||
// last_sent_contact_plane after each AP.
|
||||
_playerController.NotePositionSent(
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
worldPos: _playerController.Position,
|
||||
cellId: _playerController.CellId,
|
||||
contactPlane: _playerController.ContactPlane,
|
||||
nowSeconds: _playerController.SimTimeSeconds);
|
||||
}
|
||||
|
||||
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
|
||||
|
|
@ -9106,45 +9126,51 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// 2026-05-16 — R is conceptually "use." It smart-routes to
|
||||
// pickup as a downstream optimization (see the isPickupableItem
|
||||
// dispatch below), but the GATE is always IsUseableTarget —
|
||||
// what retail's UseObject would do.
|
||||
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
|
||||
// key. Retail dispatches by TARGET TYPE first; the useability gate
|
||||
// is enforced by each individual action handler (SendUse checks
|
||||
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
|
||||
// a top-level block. Previously the IsUseableTarget gate at the
|
||||
// entry point rejected USEABLE_NO=1 items (spell components,
|
||||
// gems) which retail accepts as pickupable — they just aren't
|
||||
// "useable" in the activate-from-world sense.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. Creature → SendUse (talk to NPC, attack monster)
|
||||
// 2. Pickupable → SendPickUp (small items, corpses)
|
||||
// 3. Useable → SendUse (doors, portals, lifestones,
|
||||
// potions / scrolls activated from world)
|
||||
// 4. Else → toast "X cannot be used" (signs, banners,
|
||||
// decorative scenery)
|
||||
//
|
||||
// Retail string at acclient_2013_pseudo_c.txt:1033115
|
||||
// (data_7e2a70): "The %s cannot be used".
|
||||
if (!IsUseableTarget(sel))
|
||||
|
||||
bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info)
|
||||
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
|
||||
if (isCreature)
|
||||
{
|
||||
string label = DescribeLiveEntity(sel);
|
||||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[B.4b] R-key ignored — not useable guid=0x{sel:X8}");
|
||||
SendUse(sel);
|
||||
return;
|
||||
}
|
||||
|
||||
// B.7 (2026-05-15): the user requested R behave as a universal
|
||||
// interact key — pickup for items, use for NPCs / doors /
|
||||
// lifestones / portals / corpses. Matches retail's "use"
|
||||
// behaviour where the action picked depends on the target's
|
||||
// type rather than forcing the player to remember a different
|
||||
// hotkey per target type.
|
||||
bool isPickupableItem = true;
|
||||
if (_liveEntityInfoByGuid.TryGetValue(sel, out var info)
|
||||
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
|
||||
if (IsPickupableTarget(sel))
|
||||
{
|
||||
// NPCs / monsters / players are Use targets, never PickUp.
|
||||
isPickupableItem = false;
|
||||
}
|
||||
if (_lastSpawnByGuid.TryGetValue(sel, out var spawn)
|
||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE → Use, not PickUp.
|
||||
// (acclient.h:6431-6463)
|
||||
const uint NonPickupMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
if ((odf & NonPickupMask) != 0) isPickupableItem = false;
|
||||
SendPickUp(sel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPickupableItem) SendPickUp(sel);
|
||||
else SendUse(sel);
|
||||
if (IsUseableTarget(sel))
|
||||
{
|
||||
SendUse(sel);
|
||||
return;
|
||||
}
|
||||
|
||||
string label = DescribeLiveEntity(sel);
|
||||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
|
||||
}
|
||||
|
||||
private void SendUse(uint guid)
|
||||
|
|
@ -9188,22 +9214,17 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// Far range: send Use immediately so ACE has the request,
|
||||
// AND queue an arrival re-send. Issue #63 (server-initiated
|
||||
// MoveToObject not honored) means ACE's first-Use response
|
||||
// is dropped as too-far and ACE doesn't re-poll
|
||||
// WithinUseRadius when the speculative local walk gets us in
|
||||
// range. The arrival re-send fires a second Use packet once
|
||||
// the body reaches the target — at which point ACE accepts
|
||||
// and executes the action. The retail-faithful path is to
|
||||
// honor MoveToObject and let ACE complete the Use server-
|
||||
// side; until #63 lands, this client-side retry is the
|
||||
// workaround that keeps far-range Use working.
|
||||
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
||||
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
||||
// it server-side when WithinUseRadius passes during the
|
||||
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
||||
// needed — the Phase B.6 MoveToState-suppression fix
|
||||
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
||||
// walk.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||
_liveSession.SendGameAction(body);
|
||||
_pendingPostArrivalAction = (guid, false);
|
||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string label = DescribeLiveEntity(guid);
|
||||
|
|
@ -9263,16 +9284,16 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// Far range: same arrival-retry pattern as SendUse — fire
|
||||
// PickUp immediately AND queue for arrival re-send. ACE's
|
||||
// first PickUp is dropped if we're outside the use-radius
|
||||
// and isn't re-polled (issue #63 workaround).
|
||||
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
||||
// callback pattern as SendUse — ACE's chain fires
|
||||
// PutItemInContainer/Move server-side when in range. No
|
||||
// client-side retry; Phase B.6 MoveToState suppression keeps
|
||||
// ACE's chain alive.
|
||||
var seq = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||
_liveSession.SendGameAction(body);
|
||||
_pendingPostArrivalAction = (itemGuid, true);
|
||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string label = DescribeLiveEntity(itemGuid);
|
||||
|
|
@ -9665,56 +9686,75 @@ public sealed class GameWindow : IDisposable
|
|||
/// pickup flow for entities where ACE didn't publish useability.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
||||
///
|
||||
/// <para>
|
||||
/// The earlier <c>(useability & USEABLE_REMOTE) != 0u</c> check
|
||||
/// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
|
||||
/// action ("can the player activate this item from the world");
|
||||
/// PICKUP is a separate action governed by retail's
|
||||
/// PutItemInContainer handler, which accepts any small-item-class
|
||||
/// entity from the world regardless of useability bits. A spell
|
||||
/// component with useability=USEABLE_NO=1 is still pickupable in
|
||||
/// retail — USEABLE_NO blocks using the component (you can't
|
||||
/// "activate" it standalone), not picking it up.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
|
||||
/// → pickupable. Server validates the request server-side
|
||||
/// (in-range, target-still-exists, container-has-room).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private bool IsPickupableTarget(uint guid)
|
||||
{
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
return false;
|
||||
|
||||
// 2026-05-16 — primary discriminator is the BF_STUCK
|
||||
// ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
|
||||
// ACE mark immovable world objects (signs, banners, doors,
|
||||
// benches) as Stuck server-side. ACE's PutItemInContainer
|
||||
// handler (Player_Inventory.cs:831-836) responds with
|
||||
// WeenieError.Stuck (0x29) when the client attempts a pickup
|
||||
// on an item with the Stuck flag — so the client should gate
|
||||
// out signs etc. before sending the wire packet.
|
||||
//
|
||||
// Discriminates same-ItemType ambiguity that useability can't:
|
||||
// Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
|
||||
// Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
|
||||
// Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
|
||||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
if (spawn.Useability is uint useability)
|
||||
{
|
||||
const uint USEABLE_REMOTE = 0x20u;
|
||||
return (useability & USEABLE_REMOTE) != 0u;
|
||||
}
|
||||
|
||||
// Useability null: corpses are pickupable; signs aren't.
|
||||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
const uint BF_CORPSE = 0x2000u;
|
||||
if ((odf & BF_CORPSE) != 0u)
|
||||
{
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[useability-fallback] pickup-corpse guid=0x{guid:X8} (ACE sent no useability bit)"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Small-item ItemType fallback (covers F on dropped items
|
||||
// when ACE doesn't publish useability for the weenie).
|
||||
uint it = spawn.ItemType ?? 0u;
|
||||
const uint SmallItemMask =
|
||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||||
| AcDream.Core.Items.ItemType.Armor
|
||||
| AcDream.Core.Items.ItemType.Clothing
|
||||
| AcDream.Core.Items.ItemType.Jewelry
|
||||
| AcDream.Core.Items.ItemType.Food
|
||||
| AcDream.Core.Items.ItemType.Money
|
||||
| AcDream.Core.Items.ItemType.Misc
|
||||
| AcDream.Core.Items.ItemType.MissileWeapon
|
||||
| AcDream.Core.Items.ItemType.Container
|
||||
| AcDream.Core.Items.ItemType.Gem
|
||||
| AcDream.Core.Items.ItemType.SpellComponents
|
||||
| AcDream.Core.Items.ItemType.Writable
|
||||
| AcDream.Core.Items.ItemType.Key
|
||||
| AcDream.Core.Items.ItemType.Caster);
|
||||
if ((it & SmallItemMask) != 0u)
|
||||
{
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[useability-fallback] pickup-smallitem guid=0x{guid:X8} itemType=0x{it:X8} (ACE sent no useability bit)"));
|
||||
return true;
|
||||
}
|
||||
const uint BF_STUCK = 0x0004u;
|
||||
const uint BF_CORPSE = 0x2000u;
|
||||
// Corpses are pickupable (loot) — BF_CORPSE wins over
|
||||
// any BF_STUCK that might be coincidentally set.
|
||||
if ((odf & BF_CORPSE) != 0u) return true;
|
||||
// Anything else with BF_STUCK is immovable scenery.
|
||||
if ((odf & BF_STUCK) != 0u) return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
// Small-item ItemType class: dropped weapons, armor, food,
|
||||
// jewelry, money, misc, gems, spell components, etc.
|
||||
uint it = spawn.ItemType ?? 0u;
|
||||
const uint SmallItemMask =
|
||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||||
| AcDream.Core.Items.ItemType.Armor
|
||||
| AcDream.Core.Items.ItemType.Clothing
|
||||
| AcDream.Core.Items.ItemType.Jewelry
|
||||
| AcDream.Core.Items.ItemType.Food
|
||||
| AcDream.Core.Items.ItemType.Money
|
||||
| AcDream.Core.Items.ItemType.Misc
|
||||
| AcDream.Core.Items.ItemType.MissileWeapon
|
||||
| AcDream.Core.Items.ItemType.Container
|
||||
| AcDream.Core.Items.ItemType.Gem
|
||||
| AcDream.Core.Items.ItemType.SpellComponents
|
||||
| AcDream.Core.Items.ItemType.Writable
|
||||
| AcDream.Core.Items.ItemType.Key
|
||||
| AcDream.Core.Items.ItemType.Caster);
|
||||
return (it & SmallItemMask) != 0u;
|
||||
}
|
||||
|
||||
private string DescribeLiveEntity(uint guid)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue