fix(retail): rotation rate, useability gate, retail toast strings

Two retail divergences fixed from the 2026-05-16 faithfulness audit
(Commit A of the plan at docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md).

1. Rotation rate ignored HoldKey.Run. Retail's CMotionInterp::
   apply_run_to_command (decomp 0x00527be0 line 305098) multiplies
   turn_speed by run_turn_factor (1.5, PDB-named symbol at 0x007c8914)
   when input is TurnRight/TurnLeft under HoldKey.Run. Effective
   running rotation is 50% faster (~135°/s vs walking ~90°/s).
   Our keyboard A/D and ApplyAutoWalkOverlay used a fixed walking
   rate.

   New: RemoteMoveToDriver.TurnRateFor(running) helper. Keyboard
   path passes input.Run; auto-walk overlay passes
   _autoWalkInitiallyRunning. The walking-rate base
   (BaseTurnRateRadPerSec = π/2) is unchanged; TurnRateRadPerSec
   constant is preserved as the walking-rate alias for callers
   that don't have run/walk state (NPC remotes).

2. IsUseableTarget gated on `useability & USEABLE_REMOTE (0x20)`,
   which was stricter than retail. Per ItemUses::IsUseable
   (acclient_2013_pseudo_c.txt:256455) cross-referenced with 4
   call sites, retail's IsUseable() semantic is `_useability != 0`.
   But visually retail's USEABLE_NO (1) entities don't approach
   either, because ACE never broadcasts MovementType=6 for them.
   Our client installs a speculative auto-walk BEFORE the server
   responds, so we'd visibly approach + face signs before the
   wire packet was rejected.

   Pragmatic fix: block USEABLE_UNDEF (0) AND USEABLE_NO (1) in
   IsUseableTarget — slightly stricter than retail's
   IsUseable but matches retail's user-visible behaviour
   ("R on sign does nothing"). Documented in the doc-comment so
   a future implementer knows the gap.

3. New IsPickupableTarget gate for F-key path — requires
   USEABLE_REMOTE (0x20) bit. Null-useability fallback for
   BF_CORPSE + small-item ItemTypes (preserves M1 ground-item
   pickup flow when ACE seed DB doesn't publish useability).

4. R-key (UseCurrentSelection) upfront gate now ALWAYS uses
   IsUseableTarget. R is conceptually "use" with smart-routing
   to pickup as a downstream optimization. F-key (SendPickUp)
   uses IsPickupableTarget directly.

5. Retail toast strings on block, centralised in new
   src/AcDream.Core/Ui/RetailMessages.cs:
   - "The X cannot be used" (data 0x007e2a70, sprintf 0x00588ea4)
     fires on UseCurrentSelection / SendUse gate block.
   - "The X can't be picked up!" (sprintf 0x00587353) fires on
     SendPickUp non-pickupable block.
   - "You cannot pick up creatures!" (data 0x007e22b4) fires on
     SendPickUp creature block (was previously silent).
   - Plus 4 inactive retail strings ready for future call sites:
     CannotBeUsedWith (two-target Use), CannotBePickedUp (formal
     pickup variant), CannotBeUsedWhileOnHook_HooksOff +
     CannotBeUsedWhileOnHook_NotOwner (housing). All cite their
     retail data addresses + runtime sprintf addresses.

6. ProbeUseabilityFallbackEnabled diagnostic (env var
   ACDREAM_PROBE_USEABILITY_FALLBACK=1) logs every time the
   null-useability fallback fires. Settles whether the
   fallback for creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE
   entries in ACE's seed DB without useability is hot code
   or theoretical defense.

Test coverage:
- +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/back-compat.
- +7 RetailMessagesTests cover each retail string with retail anchor.
- +1 CreateObjectTests TryParse_WeenieFlagsUsable_ReadsUseableNoValue
  pins parser correctness for USEABLE_NO=1.
- 294/294 Core.Net pass; 24/24 new+touched Core tests pass.
- Pre-existing baseline of 8 Physics test failures unchanged
  (BSPStepUp + MotionInterpreter regression noise from prior
  sessions; out of scope here).

Deferred to a separate session per user direction:
- Click area = indicator-rect retail fidelity. Retail's picker
  uses per-part CGfxObj.drawing_sphere + polygon refine
  (0x0054c740); ours uses single Setup.SelectionSphere ray-
  intersect. The rect corners are dead zones today. Three fix
  options analyzed: screen-space rectangle hit-test, sqrt(2)
  sphere inflation, polygon refine Stage B.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-16 12:17:54 +02:00
parent f4f4143ac0
commit e0d5d271f3
9 changed files with 2040 additions and 38 deletions

View file

@ -502,7 +502,12 @@ public sealed class PlayerMovementController
// MathF.Min(|delta|, maxStep) naturally clamps the final
// fractional step to exactly delta, so we land on the
// target heading without overshoot.
float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
// 2026-05-16 — retail-faithful turn rate. Auto-walk knows
// its run/walk decision from _autoWalkInitiallyRunning
// (set at BeginServerAutoWalk based on initial distance vs
// WalkRunThreshold). Running rotation is 50% faster per
// run_turn_factor at retail 0x007c8914.
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
@ -637,10 +642,21 @@ public sealed class PlayerMovementController
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// - CMotionInterp::apply_run_to_command 0x00527be0
// multiplies turn_speed by run_turn_factor (1.5) under
// HoldKey.Run on TurnRight/TurnLeft commands.
// - Base rate ±π/2 rad/s comes from add_motion 0x005224b0
// with HasOmega-cleared MotionData fallback.
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
// close to retail walking but no run differentiation.
float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run);
if (input.TurnRight)
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
Yaw -= keyboardTurnRate * dt;
if (input.TurnLeft)
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw += keyboardTurnRate * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;

View file

@ -9120,16 +9120,18 @@ public sealed class GameWindow : IDisposable
return;
}
// 2026-05-15: retail-faithful useability gate. Signs / banners /
// decorative scenery have ITEM_USEABLE = USEABLE_UNDEF (acclient.h:6478)
// and the retail client silently ignores R-key on them — no walk,
// no packet, no toast. We honor that here. The user can still
// left-click to select and see the entity's name; only the
// interact action is suppressed.
// 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.
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used".
if (!IsUseableTarget(sel))
{
string label = DescribeLiveEntity(sel);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use ignored — not useable guid=0x{sel:X8}");
Console.WriteLine($"[B.4b] R-key ignored — not useable guid=0x{sel:X8}");
return;
}
@ -9175,6 +9177,12 @@ public sealed class GameWindow : IDisposable
// action we previously gated through.
if (!isRetryAfterArrival && !IsUseableTarget(guid))
{
// Retail-style client-side toast for unusable targets
// (signs, decorative scenery with USEABLE_NO / USEABLE_UNDEF).
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used" (no trailing period).
string label = DescribeLiveEntity(guid);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
return;
@ -9231,16 +9239,29 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
// 2026-05-15: useability gate (acclient.h:6478 ITEM_USEABLE).
// F-key on a non-useable entity (sign, banner, decorative
// scenery) is silently ignored — without this we'd send
// PutItemInContainer for a sign and ACE would reply with a
// noisy InventoryServerSaveFailed. Retail's client doesn't
// attempt the wire send at all.
if (!isRetryAfterArrival && !IsUseableTarget(itemGuid))
// Creature-pickup block (with retail toast). Comes BEFORE the
// generic IsPickupableTarget gate so creatures get the specific
// "cannot pick up creatures!" message instead of the generic
// "can't be picked up!".
// Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4).
if (!isRetryAfterArrival && IsLiveCreatureTarget(itemGuid))
{
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] SendPickUp ignored — not useable item=0x{itemGuid:X8}");
Console.WriteLine($"[B.5] SendPickUp ignored — creature item=0x{itemGuid:X8}");
return;
}
// Generic non-pickupable gate (signs, banners, decorative scenery).
// Retail string acclient_2013_pseudo_c.txt:401589 (sprintf
// "The %s can't be picked up!").
if (!isRetryAfterArrival && !IsPickupableTarget(itemGuid))
{
string label = DescribeLiveEntity(itemGuid);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}");
return;
}
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
@ -9259,19 +9280,6 @@ public sealed class GameWindow : IDisposable
}
}
// B.5 polish (2026-05-14): silently block client-side when the
// selected entity is a creature/NPC. ACE's
// HandleActionPutItemInContainer would otherwise reject with
// WeenieError.Stuck (0x0029, "You cannot pick that up!") AND
// trigger the NPC's emote chain, which surfaces as "the NPC
// talks to me when I press F" if the user single-clicked an
// NPC last before the F press. Use (double-click) is the right
// action for NPCs; F is only for ground items. Silent rejection
// matches retail behavior — retail showed no client-side
// feedback for this case either.
if (IsLiveCreatureTarget(itemGuid))
return;
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
@ -9672,12 +9680,48 @@ public sealed class GameWindow : IDisposable
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
{
// Authoritative path: server published Useability.
// 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
// at acclient_2013_pseudo_c.txt:256455 (4 call-site cross-
// checks confirm: ItemHolder::UseObject 0x00588a80,
// DetermineUseResult 0x402697, UsingItem 0x367638,
// disable-button state 0x198826 — all key off non-zero).
// BN's `!(x) & 1` rendering is a mis-decompile of the
// setne+and test-flag inliner. Real semantic:
//
// IsUseable(_useability) := (_useability != USEABLE_UNDEF)
//
// ANY non-zero value passes (including USEABLE_NO=1,
// USEABLE_CONTAINED=8, etc.). Retail trusts the server to
// have only set non-zero on entities where Use is sensible.
//
// Previous implementation (B.8) checked
// `(useability & USEABLE_REMOTE_BIT) != 0` which is STRICTER
// than retail — a USEABLE_NO door would be blocked locally
// but pass retail's gate. Now matches retail bit-for-bit.
if (spawn.Useability is uint useability)
{
// USEABLE_REMOTE (0x20) — bit set in every from-world
// useable variant per acclient.h:6478 ITEM_USEABLE enum.
const uint USEABLE_REMOTE_BIT = 0x20u;
return (useability & USEABLE_REMOTE_BIT) != 0;
// Retail-faithful Use gate per acclient_2013_pseudo_c.txt:256455
// ItemUses::IsUseable: non-zero useability passes. But two
// values produce "cannot be used" client-side without a
// wire send in retail's observable behaviour:
// USEABLE_UNDEF (0): server's Use handler would reject;
// retail UseObject path shows "cannot be used" toast.
// USEABLE_NO (1): explicitly not useable — same outcome.
// Both come from acclient.h:6478 ITEM_USEABLE enum.
//
// Retail technically sends the packet for USEABLE_NO (the
// audit's `IsUseable != 0` reading is correct), but ACE
// never broadcasts MovementType=6 for it, so retail
// doesn't visibly approach. Our client installs a
// speculative auto-walk overlay BEFORE the server
// response — so the only way to avoid "approach then fail"
// is to gate USEABLE_NO client-side. Net result matches
// user-observed retail behaviour.
const uint USEABLE_UNDEF = 0u;
const uint USEABLE_NO = 1u;
if (useability == USEABLE_UNDEF || useability == USEABLE_NO)
return false;
return true;
}
// Useability NOT in PWD — fall back to known-useable types.
@ -9686,15 +9730,29 @@ public sealed class GameWindow : IDisposable
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & UseableFlatMask) != 0) return true;
if ((odf & UseableFlatMask) != 0)
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[useability-fallback] flat-class guid=0x{guid:X8} odf=0x{odf:X8} (ACE sent no useability bit)"));
return true;
}
}
}
// Creatures (NPCs / players) are always Use targets (dialogue,
// PvP target). Keeps the fallback permissive for the M1 flows.
// Creatures (NPCs / players) are always Use targets in our
// fallback even when ACE didn't publish useability. Retail
// would have blocked here (null → USEABLE_UNDEF → 0 → block),
// but ACE's seed DB has many talk-only NPC weenies with
// `ItemUseable = null`; without the fallback the M1 "click NPC"
// flow regresses. The diagnostic line below lets us measure
// how often this branch fires in real play.
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[useability-fallback] creature guid=0x{guid:X8} (ACE sent no useability bit)"));
return true;
}
@ -9704,6 +9762,81 @@ public sealed class GameWindow : IDisposable
return false;
}
/// <summary>
/// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click
/// "Pick Up." Distinct from <see cref="IsUseableTarget"/> because
/// pickup is more restrictive than Use: the entity must be useable
/// FROM THE WORLD (USEABLE_REMOTE bit, 0x20). Signs / banners with
/// USEABLE_NO (0x1) lack the REMOTE bit so pickup is blocked
/// client-side without a wire packet — matches retail's "The X can't
/// be picked up!" client-side toast.
///
/// <para>
/// Useable values that include USEABLE_REMOTE (0x20):
/// USEABLE_REMOTE (0x20), USEABLE_REMOTE_NEVER_WALK (0x60),
/// USEABLE_VIEWED_REMOTE (0x30), and the SOURCE_*_TARGET_REMOTE
/// composites in the 0x200000+ range.
/// </para>
///
/// <para>
/// Null-useability fallback: same as <see cref="IsUseableTarget"/>
/// — permit pickup for entities with BF_CORPSE bit set, and for
/// items with small-item ItemType. This preserves M1 ground-item
/// pickup flow for entities where ACE didn't publish useability.
/// </para>
/// </summary>
private bool IsPickupableTarget(uint guid)
{
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
{
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;
}
}
return false;
}
private string DescribeLiveEntity(uint guid)
{
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)