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:
parent
f4f4143ac0
commit
e0d5d271f3
9 changed files with 2040 additions and 38 deletions
1560
docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Normal file
1560
docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
105
src/AcDream.Core/Ui/RetailMessages.cs
Normal file
105
src/AcDream.Core/Ui/RetailMessages.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs
Normal file
77
tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue