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

@ -114,6 +114,26 @@ public sealed class CreateObjectTests
Assert.Equal(0x20u, parsed!.Value.Useability);
}
[Fact]
public void TryParse_WeenieFlagsUsable_ReadsUseableNoValue()
{
// Holtburg sign case (observed 2026-05-16): ACE sends
// weenieFlags=0x10 + Useability=USEABLE_NO (0x01) for signs.
// The parser must read this verbatim — downstream code
// distinguishes USEABLE_NO from USEABLE_REMOTE for the
// pickup vs use gate.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x7A9B3001u, name: "Holtburg",
itemType: 0x80u, // Misc
weenieFlags: 0x10u,
useability: 0x01u); // USEABLE_NO
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x01u, parsed!.Value.Useability);
}
[Fact]
public void TryParse_WeenieFlagsValueAndUsableAndUseRadius_AllReadInOrder()
{

View file

@ -293,4 +293,38 @@ public class RemoteMoveToDriverTests
Assert.Equal(20f, w.Y);
Assert.Equal(0f, w.Z);
}
[Fact]
public void TurnRateFor_WalkingReturnsBaseRate()
{
// Retail: omega.z = ±π/2 × turn_speed (1.0) = π/2 rad/s ≈ 90°/s
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// CMotionInterp::apply_run_to_command 0x00527be0 only
// multiplies under HoldKey.Run — walking is unscaled.
float rate = RemoteMoveToDriver.TurnRateFor(running: false);
Assert.Equal(MathF.PI / 2.0f, rate, precision: 5);
}
[Fact]
public void TurnRateFor_RunningAppliesRunTurnFactor()
{
// Retail: omega.z = ±π/2 × turn_speed × run_turn_factor
// run_turn_factor = 1.5f at 0x007c8914 (PDB-named).
// apply_run_to_command (acclient_2013_pseudo_c.txt:305098)
// multiplies turn_speed by 1.5f when input is TurnRight
// under HoldKey.Run.
float rate = RemoteMoveToDriver.TurnRateFor(running: true);
Assert.Equal(MathF.PI / 2.0f * 1.5f, rate, precision: 5);
}
[Fact]
public void TurnRateRadPerSec_BackCompatStillResolvesToWalkingRate()
{
// Existing call sites that haven't yet migrated to TurnRateFor
// (e.g., RemoteMoveToDriver.Drive's TurnSpeed=1.0 callers) still
// see the walking-rate constant. Same numerical value as
// BaseTurnRateRadPerSec.
Assert.Equal(RemoteMoveToDriver.BaseTurnRateRadPerSec,
RemoteMoveToDriver.TurnRateRadPerSec, precision: 5);
}
}

View file

@ -0,0 +1,77 @@
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Ui;
public sealed class RetailMessagesTests
{
[Fact]
public void CannotBeUsed_FormatsRetailLiteral()
{
// Retail acclient_2013_pseudo_c.txt:1033115 (data_7e2a70):
// "The %s cannot be used"
// Interpolated form with the entity name where %s sat.
Assert.Equal("The Holtburg cannot be used",
RetailMessages.CannotBeUsed("Holtburg"));
}
[Fact]
public void CantBePickedUp_FormatsRetailLiteral()
{
// Retail acclient_2013_pseudo_c.txt:401589 sprintf:
// "The %s can't be picked up!"
Assert.Equal("The Holtburg can't be picked up!",
RetailMessages.CantBePickedUp("Holtburg"));
}
[Fact]
public void CannotPickUpCreatures_IsExactRetailLiteral()
{
// Retail acclient_2013_pseudo_c.txt:1033034 (data_7e22b4):
// "You cannot pick up creatures!"
// Constant; no placeholder.
Assert.Equal("You cannot pick up creatures!",
RetailMessages.CannotPickUpCreatures);
}
[Fact]
public void CannotBeUsedWith_FormatsRetailLiteral()
{
// Retail acclient_2013_pseudo_c.txt:1024669 (data_7cc834):
// "Cannot be used with %s"
Assert.Equal("Cannot be used with Lockpick",
RetailMessages.CannotBeUsedWith("Lockpick"));
}
[Fact]
public void CannotBePickedUp_FormatsFormalRetailVariant()
{
// Retail acclient_2013_pseudo_c.txt:1033033 (data_7e227c):
// "The %s cannot be picked up!"
// FORMAL variant — distinct from informal CantBePickedUp.
Assert.Equal("The Holtburg cannot be picked up!",
RetailMessages.CannotBePickedUp("Holtburg"));
}
[Fact]
public void CannotBeUsedWhileOnHook_HooksOff_PreservesTrailingNewline()
{
// Retail acclient_2013_pseudo_c.txt:1029591 (data_7d1f68).
// Trailing \n is part of the retail literal.
string actual = RetailMessages.CannotBeUsedWhileOnHook_HooksOff("Chest");
Assert.Equal(
"The Chest cannot be used while on a hook, use the '@house hooks on' command to make the hook openable.\n",
actual);
Assert.EndsWith("\n", actual);
}
[Fact]
public void CannotBeUsedWhileOnHook_NotOwner_PreservesTrailingNewline()
{
// Retail acclient_2013_pseudo_c.txt:1030063 (data_7d5f30).
string actual = RetailMessages.CannotBeUsedWhileOnHook_NotOwner("Chest");
Assert.Equal(
"The Chest cannot be used while on a hook and only the owner may open the hook.\n",
actual);
Assert.EndsWith("\n", actual);
}
}