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

@ -120,4 +120,25 @@ public static class PhysicsDiagnostics
/// </summary>
public static bool ProbeAutoWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1";
/// <summary>
/// 2026-05-16. Logs one line per `IsUseableTarget` call that takes
/// the null-useability fallback path (creature pass / BF_DOOR pass /
/// BF_LIFESTONE pass / etc.). Used to measure how often ACE's seed
/// DB ships entities without `_useability` set — settles whether
/// the fallback is live code or theoretical defense.
///
/// <para>
/// Retail has NO fallback; null/zero useability blocks Use entirely
/// (acclient_2013_pseudo_c.txt:402923 ItemHolder::UseObject —
/// IsUseable==0 falls through to "cannot be used" branch). Our
/// fallback exists because ACE genuinely sends null for many seed
/// weenies. The probe quantifies "many".
/// </para>
///
/// <para>Toggle via env var <c>ACDREAM_PROBE_USEABILITY_FALLBACK=1</c>
/// or DebugPanel checkbox.</para>
/// </summary>
public static bool ProbeUseabilityFallbackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1";
}

View file

@ -76,6 +76,42 @@ public static class RemoteMoveToDriver
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail base turn rate for the player Humanoid when turn_speed
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
/// HasOmega-cleared MotionData fallback documented in
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
/// </summary>
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
/// command. Effect: running rotation is 50 % faster than walking.
/// </summary>
public const float RunTurnFactor = 1.5f;
/// <summary>
/// Retail-faithful local-player turn rate.
/// <list type="bullet">
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
/// ≈ 135°/s.</item>
/// </list>
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
/// access to the player's run/walk state (keyboard A/D, auto-walk
/// overlay turn-first). NPC/monster remotes that lack the
/// information continue to use the constant which equals
/// <c>BaseTurnRateRadPerSec</c>.
/// </summary>
public static float TurnRateFor(bool running)
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
: BaseTurnRateRadPerSec;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> in a chase packet, exact equality is

View file

@ -0,0 +1,105 @@
namespace AcDream.Core.Ui;
/// <summary>
/// Verbatim ports of retail UI message strings. Centralised here so
/// future retail-faithful refinements only need to touch one file —
/// and so the call sites stay readable at the interaction layer.
///
/// <para>
/// String text is byte-identical with retail. Each helper cites the
/// retail DAT data address + the runtime use site in the named decomp
/// at <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>.
/// </para>
///
/// <para>
/// Pattern mirrors <see cref="RadarBlipColors"/> — typed port of a
/// retail-UI primitive (<c>gmRadarUI::GetBlipColor</c> at
/// <c>0x004d76f0</c>). Add new strings here as we encounter them.
/// </para>
///
/// <para>
/// Members may be added BEFORE their first call site exists — retail
/// strings are a fixed inventory we know we'll need as we port more
/// features. Each member's doc-comment cites its retail anchor +
/// describes the scenario that'll consume it. Removing dead members
/// without a port is fine; we re-grep the decomp.
/// </para>
/// </summary>
public static class RetailMessages
{
/// <summary>
/// Retail: <c>"The %s cannot be used"</c>.
/// Data: <c>0x007e2a70</c> (line 1033115). Runtime sprintf at
/// <c>0x00588ea4</c> (line 403095) inside <c>ItemHolder::UseObject</c>'s
/// IsUseable==0 fallthrough branch. Shown when the player triggers
/// Use on an entity whose useability is USEABLE_UNDEF/USEABLE_NO.
/// </summary>
public static string CannotBeUsed(string entityName)
=> $"The {entityName} cannot be used";
/// <summary>
/// Retail: <c>"The %s can't be picked up!"</c>.
/// Runtime sprintf at <c>0x00587353</c> (line 401589) inside the
/// pickup-flow handler. Shown when the player triggers a pickup on
/// an entity that lacks USEABLE_REMOTE / isn't a small-item type.
/// </summary>
public static string CantBePickedUp(string entityName)
=> $"The {entityName} can't be picked up!";
/// <summary>
/// Retail: <c>"You cannot pick up creatures!"</c>.
/// Data: <c>0x007e22b4</c> (line 1033034). Runtime use at
/// <c>0x005871f4</c> (line 401642) inside the same pickup-flow
/// handler. Shown when the player triggers a pickup on a Creature
/// ItemType (NPCs, monsters, other players).
/// </summary>
public const string CannotPickUpCreatures = "You cannot pick up creatures!";
/// <summary>
/// Retail: <c>"Cannot be used with %s"</c>.
/// Data: <c>0x007cc834</c> (line 1024669). Runtime sprintf at
/// <c>0x0055ee0e</c> (line 363413). Shown when the player tries
/// a two-target Use (e.g., key on lock, lockpick on chest) and
/// the combination is invalid for the source item. The <c>%s</c>
/// is the TARGET entity name. No call site yet — wired in when
/// the two-target Use flow ships.
/// </summary>
public static string CannotBeUsedWith(string targetName)
=> $"Cannot be used with {targetName}";
/// <summary>
/// Retail: <c>"The %s cannot be picked up!"</c>. FORMAL variant.
/// Data: <c>0x007e227c</c> (line 1033033). Runtime sprintf at
/// <c>0x00587264</c> (line 401623). Distinct from
/// <see cref="CantBePickedUp(string)"/> — retail has TWO pickup-
/// reject strings (formal "cannot" + informal "can't"); they
/// fire from different code paths inside the pickup handler.
/// Use whichever the corresponding caller's retail path uses.
/// No call site yet — wired in when the formal-pickup-reject
/// path ships (probably a server-side rejection message).
/// </summary>
public static string CannotBePickedUp(string entityName)
=> $"The {entityName} cannot be picked up!";
/// <summary>
/// Retail: <c>"The %s cannot be used while on a hook, use the
/// '@house hooks on' command to make the hook openable.\n"</c>.
/// Data: <c>0x007d1f68</c> (line 1029591). Shown when the player
/// tries to Use a hooked-up item with the house's "hooks off"
/// preference set. Trailing newline matches retail. No call
/// site yet — wired in when the housing system ships.
/// </summary>
public static string CannotBeUsedWhileOnHook_HooksOff(string entityName)
=> $"The {entityName} cannot be used while on a hook, use the '@house hooks on' command to make the hook openable.\n";
/// <summary>
/// Retail: <c>"The %s cannot be used while on a hook and only
/// the owner may open the hook.\n"</c>.
/// Data: <c>0x007d5f30</c> (line 1030063). Shown when a non-owner
/// tries to Use a hooked-up item in someone else's house.
/// Trailing newline matches retail. No call site yet — wired in
/// when the housing system ships.
/// </summary>
public static string CannotBeUsedWhileOnHook_NotOwner(string entityName)
=> $"The {entityName} cannot be used while on a hook and only the owner may open the hook.\n";
}