From e0d5d271f3dce0169327084c49e8627a481dc2fe Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 16 May 2026 12:17:54 +0200 Subject: [PATCH] fix(retail): rotation rate, useability gate, retail toast strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-16-retail-faithfulness-fixes.md | 1560 +++++++++++++++++ .../Input/PlayerMovementController.cs | 22 +- src/AcDream.App/Rendering/GameWindow.cs | 203 ++- .../Physics/PhysicsDiagnostics.cs | 21 + .../Physics/RemoteMoveToDriver.cs | 36 + src/AcDream.Core/Ui/RetailMessages.cs | 105 ++ .../Messages/CreateObjectTests.cs | 20 + .../Physics/RemoteMoveToDriverTests.cs | 34 + .../Ui/RetailMessagesTests.cs | 77 + 9 files changed, 2040 insertions(+), 38 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md create mode 100644 src/AcDream.Core/Ui/RetailMessages.cs create mode 100644 tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs diff --git a/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md b/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md new file mode 100644 index 0000000..36dd6b1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md @@ -0,0 +1,1560 @@ +# Retail-faithfulness fixes (Commit A + Commit B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Retire the retail divergences flagged in the 2026-05-16 faithfulness audit. Bring rotation rate, useability gate, AP cadence, picker geometry, and the four B.6 Tier-4 workarounds in line with the named-retail decomp. + +**Architecture:** Two coherent commits. +- **Commit A** (low-risk, no protocol change): rotation rate constants, useability gate semantics, and a diagnostic probe for the useability-fallback path. +- **Commit B** (coupled rework): retail-faithful AutonomousPosition cadence (diff-driven, grounded-gated) → which retires four workarounds → plus sphere-based picker using `Setup.SelectionSphere` → plus deletion of two heuristics that become dead code. + +**Tech Stack:** C# .NET 10, xUnit tests, ImGui.NET (selection indicator path). All edits land on `main` (the user runs the client from there); investigation evidence is in `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h`. + +**Retail anchors** (cited throughout): +- `0x007c8914` `run_turn_factor = 1.5f` — rotation rate multiplier under HoldKey.Run. +- `0x4fccc0` `ItemUses::IsUseable` — gate semantics is `_useability != USEABLE_UNDEF (0)`. +- `0x006b3efb` `time_between_position_events = 1.0s` — at-rest AP heartbeat. +- `0x006b45e0` `ShouldSendPositionEvent` — diff-driven + interval cadence. +- `0x006b4770` `SendPositionEvent` — `transient_state & (CONTACT_TS | ON_WALKABLE_TS)` gate. +- `0x00588a80` `ItemHolder::UseObject` — single fire-and-forget `Event_UseEvent`. +- `0x00564900` `Handle_Item__UseDone` — server signals "use done" inbound. +- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine (we use `Setup.SelectionSphere` as a simpler equivalent for Stage A). +- `0x00518b80` `CPartArray::GetSelectionSphere` — scale formula. + +--- + +## File structure + +**Files touched in Commit A:** + +| File | Responsibility | +|---|---| +| `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` | Add `BaseTurnRateRadPerSec`, `RunTurnFactor`, `TurnRateFor(running)` helper. Keep `TurnRateRadPerSec` as the walking-rate constant (back-compat). | +| `src/AcDream.App/Input/PlayerMovementController.cs` | Wire `TurnRateFor(input.Run)` into keyboard A/D path (line ~640-643) and `TurnRateFor(_autoWalkInitiallyRunning)` into `ApplyAutoWalkOverlay` (line ~505). | +| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` | Add `ProbeUseabilityFallbackEnabled` flag + `ACDREAM_PROBE_USEABILITY_FALLBACK` env var. | +| `src/AcDream.App/Rendering/GameWindow.cs` | Replace useability gate `& USEABLE_REMOTE_BIT` with `!= 0`; add diagnostic line in fallback branches. | +| `tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs` | New file — unit tests for `TurnRateFor`. | + +**Files touched in Commit B:** + +| File | Responsibility | +|---|---| +| `src/AcDream.App/Input/PlayerMovementController.cs` | Replace `_heartbeatAccum` with diff-driven `HeartbeatDue` (position/cell change + 1s heartbeat + `IsGrounded` gate). Add `NotePositionSent(pos, cellId, now)` + `SimTimeSeconds` accessor. Delete `TinyMargin` from `ApplyAutoWalkOverlay`. Add `ApproxPositionEqual` helper. | +| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to use `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. | +| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(...)` overload taking `Func sphereForEntity`. Keep existing per-radius-callback overload for now (no callers after B8). | +| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. | +| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new sphere-resolver overload. | +| `docs/ISSUES.md` | File 3 deferred follow-ups (Triangle apex/size UX; Stage B polygon refine; cdb-probe to verify `omega.z = π/2`). | + +--- + +## Commit A — Retail-faithful useability + rotation rate + +### Task A1: Add `TurnRateFor(running)` helper to `RemoteMoveToDriver` + +**Files:** +- Modify: `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` (around `TurnRateRadPerSec` constant) +- Test: `tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs` (new file) + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs`: + +```csharp +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +public sealed class RemoteMoveToDriverTests +{ + [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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +``` +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoteMoveToDriverTests" --no-build +``` +Expected: build fails — `RemoteMoveToDriver.TurnRateFor` / `BaseTurnRateRadPerSec` don't exist yet. + +- [ ] **Step 3: Add the constants + helper to `RemoteMoveToDriver.cs`** + +In `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`, immediately after the existing `TurnRateRadPerSec` constant declaration (around line 77), add: + +```csharp + /// + /// Retail base turn rate for the player Humanoid when turn_speed + /// scalar = 1.0. Convention default omega.z = ±π/2 rad/s + /// derived from add_motion at 0x005224b0 + the + /// HasOmega-cleared MotionData fallback documented in + /// AnimationSequencer.cs:734-741. ~90°/s. + /// + public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Retail's run_turn_factor constant at 0x007c8914 + /// (PDB-named). + /// equivalent (decomp 0x00527be0, line 305098 of + /// acclient_2013_pseudo_c.txt) multiplies turn_speed + /// by 1.5 when HoldKey.Run is active on a TurnRight/TurnLeft + /// command. Effect: running rotation is 50 % faster than walking. + /// + public const float RunTurnFactor = 1.5f; + + /// + /// Retail-faithful local-player turn rate. + /// + /// Walking: BaseTurnRateRadPerSec ≈ 90°/s. + /// Running: BaseTurnRateRadPerSec × RunTurnFactor + /// ≈ 135°/s. + /// + /// Replaces the fixed TurnRateRadPerSec 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 + /// BaseTurnRateRadPerSec. + /// + public static float TurnRateFor(bool running) + => running ? BaseTurnRateRadPerSec * RunTurnFactor + : BaseTurnRateRadPerSec; +``` + +Keep the existing `TurnRateRadPerSec = MathF.PI / 2.0f;` declaration as-is — it now numerically equals `BaseTurnRateRadPerSec` and acts as the back-compat alias for callers that don't yet know walk-vs-run. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +``` +dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoteMoveToDriverTests" --no-build +``` +Expected: 3/3 pass. + +- [ ] **Step 5: Defer commit to end of Commit A (Task A6).** + +--- + +### Task A2: Wire keyboard A/D to `TurnRateFor` + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs:640-643` + +- [ ] **Step 1: Replace the `WalkAnimSpeed × 0.5f` formula with `TurnRateFor`** + +In `src/AcDream.App/Input/PlayerMovementController.cs` at line 640-643, replace: + +```csharp + if (input.TurnRight) + Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s + if (input.TurnLeft) + Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt; +``` + +with: + +```csharp + // 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 -= keyboardTurnRate * dt; + if (input.TurnLeft) + Yaw += keyboardTurnRate * dt; +``` + +- [ ] **Step 2: Build to confirm no breakage** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: build succeeds with 0 errors. + +- [ ] **Step 3: No commit yet — continue to A3.** + +--- + +### Task A3: Wire `ApplyAutoWalkOverlay` to `TurnRateFor` + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs:505` + +- [ ] **Step 1: Replace the constant with the helper** + +In `ApplyAutoWalkOverlay`, locate the line: + +```csharp + float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt; +``` + +Replace with: + +```csharp + // 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; +``` + +- [ ] **Step 2: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task A4: Add `ProbeUseabilityFallbackEnabled` diagnostic flag + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` + +- [ ] **Step 1: Add the flag following the existing pattern** + +In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, after the existing `ProbeAutoWalkEnabled` flag (around line 121), add: + +```csharp + /// + /// 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. + /// + /// + /// 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". + /// + /// + /// Toggle via env var ACDREAM_PROBE_USEABILITY_FALLBACK=1 + /// or DebugPanel checkbox. + /// + public static bool ProbeUseabilityFallbackEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1"; +``` + +- [ ] **Step 2: Build** + +Run: +``` +dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task A5: Fix useability gate to `!= 0` + add fallback diagnostic + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — function `IsUseableTarget` (search for `private bool IsUseableTarget`). + +- [ ] **Step 1: Replace the primary gate** + +Locate `IsUseableTarget` in `src/AcDream.App/Rendering/GameWindow.cs`. The body currently begins with: + +```csharp + if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)) + { + // Authoritative path: server published Useability. + 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; + } + // ... fallback (ObjectDescriptionFlags + creature) ... + } +``` + +Replace the `if (spawn.Useability is uint useability)` block (the inner content, NOT the whole method) with: + +```csharp + // 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) + { + return useability != 0u; + } +``` + +- [ ] **Step 2: Add diagnostic to the fallback branches** + +In the same `IsUseableTarget` method, locate the fallback branches that read (paraphrased): + +```csharp + if (spawn.ObjectDescriptionFlags is { } odf) + { + const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; + if ((odf & UseableFlatMask) != 0) return true; + } + } + + // Creatures (NPCs / players) are always Use targets ... + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) + && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) + { + return true; + } + + return false; +``` + +Modify to: + +```csharp + if (spawn.ObjectDescriptionFlags is { } odf) + { + const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; + 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 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; + } + + return false; +``` + +- [ ] **Step 3: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task A6: Run full test suite, visual verify, commit Commit A + +- [ ] **Step 1: Run full Core.Net test suite** + +Run: +``` +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug +``` +Expected: PASS, count >= 290 (previous baseline). + +- [ ] **Step 2: Run full Core test suite (RemoteMoveToDriver tests included)** + +Run: +``` +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug +``` +Expected: PASS for `RemoteMoveToDriverTests` (3/3); pre-existing failure baseline unchanged. + +- [ ] **Step 3: Visual verify** + +The user runs the client and confirms: +- Pressing W + Shift (run) + A/D: character rotates noticeably faster than W (walk) + A/D. Estimated 50% faster (135°/s vs 90°/s). +- Auto-walk to a far target: character turns at 135°/s during the turn-first phase. +- Pressing R on a sign: still silent no-op (Useability=0 still blocks; `0 != 0` is false). +- Pressing R on an NPC: still walks + dialogue fires (creature fallback covers null useability). +- With `ACDREAM_PROBE_USEABILITY_FALLBACK=1`: `[useability-fallback]` lines appear in console for each NPC/door interaction that takes the fallback path. + +**STOP and wait for user confirmation before committing.** + +- [ ] **Step 4: Commit A** + +When user approves, in `C:\Users\erikn\source\repos\acdream` (main checkout): + +```bash +git add src/AcDream.Core/Physics/RemoteMoveToDriver.cs \ + src/AcDream.App/Input/PlayerMovementController.cs \ + src/AcDream.Core/Physics/PhysicsDiagnostics.cs \ + src/AcDream.App/Rendering/GameWindow.cs \ + tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +git commit -m "$(cat <<'EOF' +fix(retail): rotation rate run multiplier + useability gate semantics + +Two retail divergences from the 2026-05-16 faithfulness audit: + +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 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)`, + stricter than retail. Per ItemUses::IsUseable + (acclient_2013_pseudo_c.txt:256455) cross-referenced with 4 + call sites (ItemHolder::UseObject, DetermineUseResult, + UsingItem, disable-button state), retail's real gate is + `_useability != USEABLE_UNDEF (0)` — ANY non-zero passes. + The Binary Ninja `!(x) & 1` pseudo-C is a mis-decompile of a + setne+and test-flag inliner. Now matches retail. + +3. Added ProbeUseabilityFallbackEnabled diagnostic + (env var ACDREAM_PROBE_USEABILITY_FALLBACK=1) to log every + time the null-useability fallback fires. Settles whether the + fallback (allow creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE + when ACE didn't publish useability) is live code or + theoretical defense. + +Tests: +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/ +back-compat. Existing 290+ Core.Net tests unchanged. + +Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Commit B — Retire B.6 workarounds via retail AP cadence + sphere-based picker + +### Task B1: Add diff-driven position state + `NotePositionSent` to `PlayerMovementController` + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` — state block at line 189-191, public API. + +- [ ] **Step 1: Replace the `_heartbeatAccum` state with diff-tracking state** + +In `src/AcDream.App/Input/PlayerMovementController.cs`, the existing state declaration is: + +```csharp + private float _heartbeatAccum; + public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger + public bool HeartbeatDue { get; private set; } +``` + +Replace with: + +```csharp + /// + /// 2026-05-16 — retail-faithful AP cadence. Matches retail's + /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt + /// at address 0x006b45e0) which gates on either (a) position-or-cell + /// change since the last send, or (b) at-rest 1 sec heartbeat elapsed. + /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. + /// + /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That + /// missed retail's per-frame-while-moving behaviour and forced the + /// four B.6 workarounds (arrival margin, re-send on arrival, AP + /// flush, retry flag) to compensate for the lag in ACE's server-side + /// WithinUseRadius poll. Replaced by diff-driven cadence below. + /// + public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb + + private System.Numerics.Vector3 _lastSentPos; + private uint _lastSentCellId; + private float _lastSentTime; + private bool _lastSentInitialized; + private float _simTimeSeconds; + public bool HeartbeatDue { get; private set; } + + /// Sim-time accumulator (advanced by dt at the top of Update). + /// Exposed for the network outbound layer to stamp NotePositionSent. + public float SimTimeSeconds => _simTimeSeconds; +``` + +- [ ] **Step 2: Add the `NotePositionSent` API** + +In the same file, after `EndServerAutoWalk` (~line 410 — find a logical place near the public movement API), add: + +```csharp + /// + /// 2026-05-16. Called by the network outbound layer after every + /// AutonomousPosition or MoveToState that carries the player's + /// position. Resets the diff-driven heartbeat clock so the next + /// `HeartbeatDue` evaluation requires either a fresh position + /// change OR another full HeartbeatInterval. Mirrors retail's + /// SendPositionEvent (0x006b4770) which updates + /// `last_sent_position_time` + `last_sent_position` at every + /// send, AND SendMovementEvent (0x006b4680) which also touches + /// the same shared clock (both consumers of the 1 sec window). + /// + public void NotePositionSent(System.Numerics.Vector3 worldPos, + uint cellId, + float nowSeconds) + { + _lastSentPos = worldPos; + _lastSentCellId = cellId; + _lastSentTime = nowSeconds; + _lastSentInitialized = true; + } +``` + +- [ ] **Step 3: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. The `_heartbeatAccum` field declaration is gone; existing references to `HeartbeatDue` still compile (Task B2 will populate it correctly). + +--- + +### Task B2: Replace `_heartbeatAccum` update with retail diff-driven cadence + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs:1188-1203` (the per-frame heartbeat block). + +- [ ] **Step 1: Add `_simTimeSeconds` increment at top of `Update`** + +Locate the start of `Update(float dt, ...)` and add immediately after entry: + +```csharp + _simTimeSeconds += dt; +``` + +- [ ] **Step 2: Replace the accumulator update** + +Locate the existing code around line 1188-1203 that reads (paraphrased): + +```csharp + _heartbeatAccum += dt; + bool activelyMoving = _autoWalkActive + || input.Forward || input.Backward + || input.StrafeLeft || input.StrafeRight + || input.TurnLeft || input.TurnRight; + float effectiveInterval = activelyMoving ? 0.1f : HeartbeatInterval; + HeartbeatDue = _heartbeatAccum >= effectiveInterval; + if (HeartbeatDue) _heartbeatAccum = 0f; +``` + +Replace with: + +```csharp + // 2026-05-16 — retail diff-driven AP cadence (acclient_2013_pseudo_c.txt + // 0x006b45e0 ShouldSendPositionEvent + 0x006b4770 SendPositionEvent). + // + // Rules: + // - When interval elapsed (>= 1 sec since last send): send. + // - When interval NOT elapsed: send only if position or cell + // differs from last_sent (Frame::is_equal check at + // acclient_2013_pseudo_c.txt:700248-700265). + // - SendPositionEvent gates on transient_state & + // (CONTACT_TS | ON_WALKABLE_TS) — i.e., grounded on a + // walkable surface. Airborne suppresses AP entirely. + // MoveToState carries jump/fall snapshots while airborne. + // + // Effective rate: per-frame while moving on the ground, 1 Hz at-rest + // heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model. + // + // If NotePositionSent has never been called (no network session), + // _lastSentInitialized stays false and we treat every frame as + // "first send" — HeartbeatDue fires once per frame, which matches + // "send if anything to send" semantics. + + bool intervalElapsed = !_lastSentInitialized + || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval; + + bool positionChanged = + !_lastSentInitialized + || _lastSentCellId != CellId + || !ApproxPositionEqual(_lastSentPos, _body.Position); + + // Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS + // (acclient.h:3688). Our equivalent: PhysicsBody.IsGrounded. + bool groundedOnWalkable = _body.IsGrounded; + + HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed); +``` + +If `PhysicsBody.IsGrounded` doesn't exist, search the codebase for the equivalent predicate: + +``` +grep -n "IsGrounded\|OnGround\|HasContact\|GroundContact" src/AcDream.Core/Physics/PhysicsBody.cs +``` + +Use whichever exists. If neither, derive from `_body.ContactPlane.Normal.Z > 0.5f` (humanoid walkable-normal threshold per retail FloorZ ≈ 0.66). Document the choice in a comment at the call site. + +- [ ] **Step 3: Add `ApproxPositionEqual` helper** + +In the same file, near the end of the class (after the existing helper methods), add: + +```csharp + /// + /// 2026-05-16. Position-equality test for diff-driven AP cadence. + /// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263 + /// which is essentially exact float comparison after a memcmp of + /// the frame struct. For floating-point safety we use a tiny epsilon + /// — sub-millimeter — that's well below any movement we'd want to + /// suppress sending for. + /// + private static bool ApproxPositionEqual( + System.Numerics.Vector3 a, System.Numerics.Vector3 b) + { + const float Epsilon = 0.001f; // 1 mm + return MathF.Abs(a.X - b.X) < Epsilon + && MathF.Abs(a.Y - b.Y) < Epsilon + && MathF.Abs(a.Z - b.Z) < Epsilon; + } +``` + +- [ ] **Step 4: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task B3: Wire `GameWindow` to call `NotePositionSent` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — find the outbound network send sites. + +- [ ] **Step 1: Identify send call sites** + +Run: +``` +grep -n "SendAutonomousPosition\|SendMoveToState\|_playerController.HeartbeatDue\|SendGameAction" src/AcDream.App/Rendering/GameWindow.cs | head -20 +``` + +Note line numbers — typically there's: +- One heartbeat-driven AP send site (around line 6310 area). +- One input-driven MoveToState site (around the `MotionStateChanged` check). + +- [ ] **Step 2: After each successful AP/MoveToState send, call `NotePositionSent`** + +Immediately after each `_liveSession.SendGameAction(body)` for AP and MoveToState, add: + +```csharp + _playerController.NotePositionSent( + worldPos: _playerController.Position, + cellId: _playerController.CellId, + nowSeconds: _playerController.SimTimeSeconds); +``` + +For the AP heartbeat site, this is post-send. For the MoveToState site, same — also stamps the clock. + +NOTE: Do NOT add this for outbound Use / PickUp / JumpAction packets — those don't carry position. + +- [ ] **Step 3: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task B4: Delete `TinyMargin` from `ApplyAutoWalkOverlay` + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs:466-472` + +- [ ] **Step 1: Remove the margin clamp** + +Locate lines 466-472: + +```csharp + const float TinyMargin = 0.05f; + float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f); + bool withinArrival = + (_autoWalkMoveTowards + && dist <= effectiveArrival) + || (!_autoWalkMoveTowards + && dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon); +``` + +Replace with: + +```csharp + // 2026-05-16 — retail "stop at the radius" semantics. + // Previously had a 0.05 m TinyMargin inside the threshold to + // ensure ACE's server-side WithinUseRadius poll saw us inside + // the radius before our next AP heartbeat. With the + // diff-driven AP cadence (Task B2) ACE sees the final position + // the same frame we arrive — no margin needed. Retail's + // arrival check is `dist <= radius` exact at + // CMotionInterp::apply_interpreted_movement integration. + bool withinArrival = + (_autoWalkMoveTowards + && dist <= arrivalThreshold) + || (!_autoWalkMoveTowards + && dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon); +``` + +- [ ] **Step 2: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task B5: Delete `OnAutoWalkArrivedReSendAction` + `SendAutonomousPositionNow` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (delete two methods + subscription) + +- [ ] **Step 1: Find the subscription** + +Run: +``` +grep -n "AutoWalkArrived" src/AcDream.App/Rendering/GameWindow.cs +``` +Expected: at least one `+=` subscription site and the method definition at line ~9302. + +- [ ] **Step 2: Delete the subscription line** + +In `GameWindow.cs`, find the line: + +```csharp + _playerController.AutoWalkArrived += OnAutoWalkArrivedReSendAction; +``` + +Delete it (entire line). (A NEW subscription is added in Task B6 Step 4 — keep that file region noted.) + +- [ ] **Step 3: Delete the `OnAutoWalkArrivedReSendAction` method** + +Locate the method at line ~9302: + +```csharp + private void OnAutoWalkArrivedReSendAction() + { + if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending) + return; + _pendingPostArrivalAction = null; + // ... probe log ... + SendAutonomousPositionNow(); + // ... probe log ... + if (isPickup) SendPickUp(guid, isRetryAfterArrival: true); + else SendUse(guid, isRetryAfterArrival: true); + } +``` + +Delete the entire method (including any doc-comment block above it). + +- [ ] **Step 4: Delete `SendAutonomousPositionNow`** + +Locate the method at line ~9424. Delete the entire method. With retail-faithful diff-driven cadence (Task B2), the next regular AP send carries the arrived position naturally. + +- [ ] **Step 5: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: build will likely FAIL with "cannot find SendAutonomousPositionNow" or "cannot find OnAutoWalkArrivedReSendAction" or similar — those errors get fixed in B6. + +--- + +### Task B6: Remove `isRetryAfterArrival` from `SendUse`/`SendPickUp`; simplify deferred-Use to close-range turn-first only + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — methods `SendUse` and `SendPickUp` (lines ~9162 and ~9226). + +- [ ] **Step 1: Replace `SendUse` body** + +Current method signature: `private void SendUse(uint guid, bool isRetryAfterArrival = false)`. Change to `private void SendUse(uint guid)` and the body to: + +```csharp + private void SendUse(uint guid) + { + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + { + _debugVm?.AddToast("Not in world"); + return; + } + + // Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455 + // ItemUses::IsUseable). Signs / banners with useability=0 silently + // ignore Use. + if (!IsUseableTarget(guid)) + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}"); + return; + } + + // B.6 (2026-05-15): install speculative local auto-walk against + // the target so close-range Use rotates the body to face before + // the action fires. For FAR targets, ACE's CreateMoveToChain + // (Player_Move.cs:37-179) takes over via inbound MovementType=6 + // and our overlay is overwritten by ACE's wire-supplied radius. + // + // 2026-05-16: simplified — close-range deferral now fires the + // wire packet ONCE on AutoWalkArrived (turn-first done), not a + // retry of an earlier failed send. No re-send path. + bool closeRange = IsCloseRangeTarget(guid); + InstallSpeculativeTurnToTarget(guid); + + if (closeRange) + { + // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction + // will fire it after rotation completes. + _pendingPostArrivalAction = (guid, false); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); + return; + } + + // Far range: fire immediately. ACE auto-walks server-side; the + // retail-faithful AP cadence keeps ACE's WithinUseRadius poll + // in sync, so the action completes when the body arrives + // without any client-side re-send. + var seq = _liveSession.NextGameActionSequence(); + var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); + _liveSession.SendGameAction(body); + Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}"); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string label = DescribeLiveEntity(guid); + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}")); + } + } +``` + +- [ ] **Step 2: Replace `SendPickUp` body the same way** + +Drop the `isRetryAfterArrival` parameter. Final shape: + +```csharp + private void SendPickUp(uint itemGuid) + { + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + { + _debugVm?.AddToast("Not in world"); + return; + } + if (!IsUseableTarget(itemGuid)) + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[B.5] SendPickUp ignored — not useable item=0x{itemGuid:X8}"); + return; + } + + // Block creature pickup (silent — matches retail). + if (IsLiveCreatureTarget(itemGuid)) + return; + + bool closeRange = IsCloseRangeTarget(itemGuid); + InstallSpeculativeTurnToTarget(itemGuid); + + if (closeRange) + { + _pendingPostArrivalAction = (itemGuid, true); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}"); + return; + } + + var seq = _liveSession.NextGameActionSequence(); + var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( + seq, itemGuid, _playerServerGuid, placement: 0); + _liveSession.SendGameAction(body); + 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); + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}")); + } + } +``` + +- [ ] **Step 3: Add `OnAutoWalkArrivedSendDeferredAction` handler** + +Add this method to `GameWindow.cs` (anywhere in the class body — near the deleted `OnAutoWalkArrivedReSendAction` site is fine): + +```csharp + /// + /// 2026-05-16. Fires the deferred close-range Use/PickUp action + /// once the local auto-walk overlay reports arrival (i.e. the body + /// has finished rotating to face the target). Unlike the old + /// `OnAutoWalkArrivedReSendAction`, this is a FIRST send — not a + /// retry of an earlier failed send. Far-range Use/PickUp paths + /// fire the wire packet immediately at `SendUse`/`SendPickUp` time + /// and never touch `_pendingPostArrivalAction`. + /// + private void OnAutoWalkArrivedSendDeferredAction() + { + if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending) + return; + _pendingPostArrivalAction = null; + + if (_liveSession is null + || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + return; + + var seq = _liveSession.NextGameActionSequence(); + if (isPickup) + { + var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( + seq, guid, _playerServerGuid, placement: 0); + _liveSession.SendGameAction(body); + Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}"); + } + else + { + var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); + _liveSession.SendGameAction(body); + Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}"); + } + } +``` + +- [ ] **Step 4: Subscribe to `AutoWalkArrived`** + +Find where `_playerController` is constructed and other subscriptions happen (the deleted subscription from B5 Step 2 was here). Add: + +```csharp + _playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction; +``` + +- [ ] **Step 5: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. If there are remaining "isRetryAfterArrival" references, fix them — those are callers that need to drop the named-argument. + +--- + +### Task B7: Add `WorldPicker.Pick` overload taking sphere-resolver + +**Files:** +- Modify: `src/AcDream.Core/Selection/WorldPicker.cs` +- Test: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (modify or create) + +- [ ] **Step 1: Write the failing test** + +Find or create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`. Add: + +```csharp +using System.Numerics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.Core.Tests.Selection; + +public sealed class WorldPickerSphereOverloadTests +{ + [Fact] + public void Pick_SphereResolver_ReturnsNearestHit() + { + // Two entities along the +Y axis. Sphere-resolver gives each a + // tight world-space sphere centered on the entity. A ray from + // origin pointing along +Y should hit the closer entity first. + var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f }; + var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 15, 0), Scale = 1f }; + + var origin = new Vector3(0, 0, 0); + var dir = new Vector3(0, 1, 0); + Vector3 SphereCenter(WorldEntity e) => e.Position + new Vector3(0, 0, 0.9f); + + uint? picked = WorldPicker.Pick( + origin, dir, new[] { e1, e2 }, + skipServerGuid: 0u, + sphereForEntity: e => ((Vector3, float)?)(SphereCenter(e), 1.0f)); + + Assert.Equal(0x10001u, picked); + } + + [Fact] + public void Pick_SphereResolver_NullSkipsCandidates() + { + // Resolver returning null should make the picker skip the + // candidate (matches retail "no Setup → not pickable" behaviour). + var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f }; + var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f }; + + var origin = new Vector3(0, 0, 0); + var dir = new Vector3(0, 1, 0); + + uint? picked = WorldPicker.Pick( + origin, dir, new[] { e1, e2 }, + skipServerGuid: 0u, + sphereForEntity: e => e.ServerGuid == 0x10001u + ? ((Vector3, float)?)null + : ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f)); + + Assert.Equal(0x10002u, picked); + } + + [Fact] + public void Pick_SphereResolver_RespectsSkipServerGuid() + { + var e1 = new WorldEntity { ServerGuid = 0x50000001u, Position = new Vector3(0, 5, 0), Scale = 1f }; + var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f }; + + var origin = new Vector3(0, 0, 0); + var dir = new Vector3(0, 1, 0); + + uint? picked = WorldPicker.Pick( + origin, dir, new[] { e1, e2 }, + skipServerGuid: 0x50000001u, // skip player + sphereForEntity: e => ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f)); + + Assert.Equal(0x10002u, picked); + } +} +``` + +NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test constructor calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / object-initializer pattern. The tests assume property initializers are allowed; if not, switch to whatever constructor the type exposes. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +``` +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build +``` +Expected: build failure ("sphereForEntity parameter not found") or runtime failure. + +- [ ] **Step 3: Add the new `Pick` overload to `WorldPicker.cs`** + +Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing `Pick` overload — it stays for back-compat; its consumers are removed in Task B8): + +```csharp + /// + /// 2026-05-16. Retail-faithful picker overload. Caller supplies a + /// per-entity world-space sphere via + /// — typically scaled by entity + /// scale and rotated into world space (mirroring retail + /// CPartArray::GetSelectionSphere at 0x00518b80). Resolver returning + /// null skips the candidate (matches retail "no Setup → not pickable"). + /// + /// + /// Replaces the older + /// overload with the per-type-radius / vertical-offset heuristics. + /// Those heuristics existed because we didn't have the dat-supplied + /// SelectionSphere plumbed through. With this overload, the click + /// geometry matches what the target indicator draws — what you see + /// is what you click. + /// + /// + /// + /// Stage A of the picker port. Retail also does a polygon-accurate + /// refine via CPolygon::polygon_hits_ray when the sphere + /// hits (decomp 0x0054c889) — that's Stage B, deferred until visual + /// testing surfaces a sphere-only miss (issue #71). + /// + /// + public static uint? Pick( + System.Numerics.Vector3 origin, System.Numerics.Vector3 direction, + IEnumerable candidates, + uint skipServerGuid, + Func sphereForEntity, + float maxDistance = 50f) + { + if (direction.LengthSquared() < 1e-10f) return null; + + uint? bestGuid = null; + float bestT = float.PositiveInfinity; + foreach (var entity in candidates) + { + if (entity.ServerGuid == 0u) continue; + if (entity.ServerGuid == skipServerGuid) continue; + + var sphere = sphereForEntity(entity); + if (sphere is null) continue; + + var (center, radius) = sphere.Value; + if (radius <= 0f) continue; + + // Geometric ray-sphere (same math as the older overload). + var oc = origin - center; + float b = System.Numerics.Vector3.Dot(oc, direction); + float c = System.Numerics.Vector3.Dot(oc, oc) - radius * radius; + float d = b * b - c; + if (d < 0f) continue; + float sqrtD = MathF.Sqrt(d); + float t = -b - sqrtD; + if (t < 0f) t = -b + sqrtD; // ray origin inside sphere → use far exit + if (t < 0f) continue; // both roots behind ray + if (t >= maxDistance) continue; + if (t < bestT) + { + bestT = t; + bestGuid = entity.ServerGuid; + } + } + return bestGuid; + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +``` +dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build +``` +Expected: 3/3 pass. + +--- + +### Task B8: Switch `GameWindow` picker call to the sphere-resolver overload + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — find the `WorldPicker.Pick` call. + +- [ ] **Step 1: Locate the existing call** + +Run: +``` +grep -n "WorldPicker.Pick(" src/AcDream.App/Rendering/GameWindow.cs +``` + +- [ ] **Step 2: Replace the call with the sphere-resolver overload** + +Replace the existing `WorldPicker.Pick(...)` invocation (which uses `radiusForGuid` + `verticalOffsetForGuid` callbacks) with: + +```csharp + var picked = AcDream.Core.Selection.WorldPicker.Pick( + origin, direction, + _entitiesByServerGuid.Values, + skipServerGuid: _playerServerGuid, + sphereForEntity: e => + TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r) + ? ((System.Numerics.Vector3, float)?)(c, r) + : ((System.Numerics.Vector3, float)?)null, + maxDistance: 50f); +``` + +- [ ] **Step 3: Delete the per-type `radiusForGuid` and `verticalOffsetForGuid` lambda blocks** + +Both lambdas (around line ~9037 and ~9054) become dead with this change. Delete them. + +- [ ] **Step 4: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. The old `WorldPicker.Pick` overload with `radiusForGuid` callback still exists in `WorldPicker.cs` but has no callers — leave it for now; not blocking. + +--- + +### Task B9: Trim `EntityHeightFor` fallback to a single defensive default + +**Files:** +- Modify: `src/AcDream.App/UI/TargetIndicatorPanel.cs` — function `EntityHeightFor`. + +- [ ] **Step 1: Simplify the per-type cascade** + +Locate `EntityHeightFor` in `TargetIndicatorPanel.cs`. It currently has per-type branches (Creature 1.8m, Door/Lifestone/Portal/Corpse 2.4m, small items 0.8m, default 3.0m). With the sphere-projection path handling every entity that has a `SelectionSphere`, this method is now a defensive rescue path only. + +Replace the entire method body with: + +```csharp + /// + /// Defensive fallback height when the entity has no usable + /// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection + /// path active (since commit f4f4143), this fallback only fires + /// for entities whose Setup didn't bake a selection sphere — + /// rare in practice. The single 1.5 m × scale default is a sane + /// midpoint; per-type branches were retired in the 2026-05-16 + /// Commit B because the sphere path is authoritative. + /// + public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null) + { + if (scale <= 0f) scale = 1f; + return 1.5f * scale; + } +``` + +Keep all the SmallItemMask / TallStructureMask / Creature constants out — they're no longer needed. The method signature stays unchanged for ABI compat with any external callers. + +- [ ] **Step 2: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. + +--- + +### Task B10: Delete `IsTallSceneryGuid` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — method `IsTallSceneryGuid` and all callers. + +- [ ] **Step 1: Find call sites** + +Run: +``` +grep -n "IsTallSceneryGuid" src/AcDream.App/Rendering/GameWindow.cs +``` + +Expected hits: +- Definition (~line 9550-9610 area) +- Two call sites inside the (deleted in B8) `radiusForGuid` / `verticalOffsetForGuid` lambdas — should be gone after B8 +- One call site in the `[B.7] pick-info` diagnostic line at ~line 9105 + +- [ ] **Step 2: Remove `IsTallSceneryGuid` from the pick-info diagnostic** + +In the `[B.7] pick-info` log, find the line: + +```csharp + Console.WriteLine(System.FormattableString.Invariant( + $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} tallScenery={IsTallSceneryGuid(guid)} color=({col.R},{col.G},{col.B})")); +``` + +Replace with (drop the `tallScenery=` field): + +```csharp + Console.WriteLine(System.FormattableString.Invariant( + $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})")); +``` + +- [ ] **Step 3: Delete the `IsTallSceneryGuid` method** + +Find the method definition (the comment block above it starts with "2026-05-15. True when the entity is 'tall scenery'..."). Delete the entire method (comment block + body). + +- [ ] **Step 4: Build** + +Run: +``` +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug +``` +Expected: 0 errors. No remaining `IsTallSceneryGuid` references. + +--- + +### Task B11: Run full test suite, visual verify, commit Commit B + +- [ ] **Step 1: Run full test suite** + +Run: +``` +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug +``` +Expected: Core.Net 290+ pass; Core baseline unchanged + 3 new WorldPickerSphereOverloadTests pass. + +- [ ] **Step 2: Visual verify with `ACDREAM_PROBE_USEABILITY_FALLBACK=1` + `ACDREAM_PROBE_AUTOWALK=1`** + +User runs the client and confirms: +1. **Far-range Use on NPC.** Click NPC at 8 m, press R. Expected: walks, turns to face, dialogue fires. Log shows ONE `[B.4b] use` line, NOT multiple. No `use-deferred` line (far-range fires immediately). +2. **Close-range Use on NPC behind player.** Stand within 2 m of NPC facing away. Press R. Expected: character turns 180°, then dialogue fires. Log shows `[B.4b] use deferred (close-range, turn-first)` followed by `[B.4b] use-deferred` (the deferred fire on arrival). Exactly ONE deferred fire. +3. **Open inn door from across the room.** Walks, opens. ONE `[B.4b] use` line. +4. **Pickup item from across the room.** Walks, picks up. ONE `[B.5] pickup` line. +5. **Click the Holtburg sign.** Indicator triangles match the sign size (unchanged from previous ship). Press R. Silent no-op (`SendUse ignored — not useable`). +6. **Click rapidly between NPC and item.** No spurious "I clicked X earlier and now it's firing on Y" cross-contamination. The `_pendingPostArrivalAction` simplification should make this clean. + +**If any of (1)-(6) regresses, the cadence fix in Task B2 likely needs tuning. Common cause: `IsGrounded` is too restrictive (suppressing AP on slopes); relax to `ContactPlane.Normal.Z > 0.3f` or similar.** + +**STOP and wait for user confirmation before committing.** + +- [ ] **Step 3: Commit B** + +When user approves: + +```bash +git add src/AcDream.App/Input/PlayerMovementController.cs \ + src/AcDream.App/Rendering/GameWindow.cs \ + src/AcDream.Core/Selection/WorldPicker.cs \ + src/AcDream.App/UI/TargetIndicatorPanel.cs \ + tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs +git commit -m "$(cat <<'EOF' +fix(retail): per-tick AP cadence + sphere picker retires 4 workarounds + +Single coherent commit. Audit findings from 2026-05-16: + +1. AutonomousPosition cadence was 1 Hz idle / 10 Hz active flat. + Retail (CommandInterpreter::ShouldSendPositionEvent at + acclient_2013_pseudo_c.txt:0x006b45e0) is diff-driven: + - Position or cell changed since last send → send (per frame + while moving, 30-60 Hz in practice). + - Otherwise, 1 sec heartbeat (time_between_position_events + constant at 0x006b3efb). + - Gated on transient_state & (CONTACT_TS | ON_WALKABLE_TS) — + suppressed airborne. + Replaced HeartbeatAccum with diff-driven HeartbeatDue + + NotePositionSent (resets the clock at every wire send, including + MoveToState — matches retail's SendMovementEvent 0x006b4680 + sharing the same `last_sent_position_time` clock). + +2. The diff-driven cadence retires all 4 B.6/B.7 workarounds: + - TinyMargin (0.05 m inside arrival): retail stops at the radius + exact; the safety margin existed because ACE's WithinUseRadius + poll missed our infrequent position updates. With per-frame + AP while moving, ACE sees us arrive the same frame. + - OnAutoWalkArrivedReSendAction: retail's Event_UseEvent + (acclient_2013_pseudo_c.txt:0x00588a80 ItemHolder::UseObject + line 403043) is single-fire, fire-and-forget. Arrival is + signaled INBOUND by the server via 0xF63E UseDone + (Handle_Item__UseDone 0x00564900) — the client doesn't + re-send. Our re-send was actively re-triggering ACE's + MoveToChain via StopExistingMoveToChains, masking the + cadence bug. + - SendAutonomousPositionNow flush: retail has no flush event; + per-frame AP while moving makes it unnecessary. + - isRetryAfterArrival flag: plumbing for the re-send; + deleted with it. + + Close-range turn-first deferred Use is KEPT (it IS retail — + ACE's Player_Move.cs:66-87 Rotate(target) before callback + mirrors retail's CreateMoveToChain pre-callback rotation). + Renamed to OnAutoWalkArrivedSendDeferredAction to clarify + it's a FIRST send, not a retry. + +3. WorldPicker switched to a Setup.SelectionSphere overload. + Retail's picker uses CGfxObj.drawing_sphere + polygon refine + (acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay), + which we approximate via Setup.SelectionSphere (same data + path as the target indicator since f4f4143). Effect: click + geometry matches the visible indicator — what you see is what + you click. Retires the per-type radius (1.0/1.5/2.0 m) and + vertical-offset (0.9/1.0/1.5 m) heuristic callbacks. + +4. EntityHeightFor fallback trimmed to a single 1.5 m default. + IsTallSceneryGuid deleted entirely — both became dead code + when the picker switched to SelectionSphere. + +Test suite: 290+ Core.Net unchanged, +3 WorldPickerSphereOverloadTests. + +Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Deferred follow-ups (file as issues, not in this plan's commits) + +### Task DF1: File issue — triangle apex/size UX + +- [ ] **Step 1: Append issue to `docs/ISSUES.md`** + +After the existing "Active issues" section, add a new entry: + +```markdown +## #70 — Triangle apex/size — final retail-feel UX pass + +**Status:** OPEN +**Severity:** LOW (cosmetic — indicator already retail-anchored, this is final-feel polish) +**Filed:** 2026-05-16 +**Component:** ui / target indicator + +**Description:** Per 2026-05-16 user feedback during the +SelectionSphere indicator ship, the triangle apex direction (flipped +to point inward at the target) and sprite size (currently 8 px legs) +are heuristic visual choices. Retail uses an actual DAT sprite from +`UIRegion::GetChild(0x1000003a/3b/3c)` — the bitmap shape and size +come from the dat, not constants. + +**Acceptance:** Extract the retail triangle sprite from the dat +(probably via `tools/UiLayoutMockup` or a new `DatSpriteProbe`) and +either (a) blit the exact bitmap, or (b) pick a procedural size + +shape that matches it pixel-for-pixel at standard zoom. + +**Estimated scope:** Small (~1-2 hours, mostly dat exploration). +Not blocking M1. +``` + +### Task DF2: File issue — picker Stage B polygon refine + +- [ ] **Step 1: Append issue to `docs/ISSUES.md`** + +```markdown +## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks + +**Status:** OPEN +**Severity:** LOW (Stage A — sphere picker — is sufficient for M1) +**Filed:** 2026-05-16 +**Component:** selection / picker + +**Description:** Retail's mouse picker does two-tier sphere-then-polygon +selection (acclient_2013_pseudo_c.txt:0x0054c740 +`Render::GfxObjUnderSelectionRay`): +1. Sphere reject via `CGfxObj::drawing_sphere`. +2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every + visual polygon; closest-t polygon hit wins over any sphere hit. + +Our Stage A (shipped 2026-05-16 Commit B) does sphere-only against +`Setup.SelectionSphere`. This will under-pick visible mesh that +extends beyond the sphere (creature's outstretched arm, sign edge +poking past sphere boundary) — exactly what retail's polygon refine +catches. + +**Acceptance:** Pipe per-part GfxObj visual polygons through a +`PickPolygonProvider` interface (don't duplicate mesh decoding — +hook the existing `ObjectMeshManager` cached data). Two-tier in +`WorldPicker.Pick`: sphere reject → polygon scan → polygon hit +dominates sphere hit. + +**Estimated scope:** Medium (~4-6 hours). Defer until visual +verification surfaces a Stage A miss in real play. +``` + +### Task DF3: File issue — cdb probe to confirm `omega.z = π/2` base rate + +- [ ] **Step 1: Append issue to `docs/ISSUES.md`** + +```markdown +## #72 — Confirm Humanoid TurnRight/TurnLeft `omega.z` base rate via cdb + +**Status:** OPEN +**Severity:** LOW (current ±π/2 fallback matches all corroborating +evidence; cdb probe would settle the open question for good) +**Filed:** 2026-05-16 +**Component:** physics / rotation / research + +**Description:** The retail rotation rate landed in Commit A +(2026-05-16) uses `BaseTurnRateRadPerSec = π/2` based on the +documented `AnimationSequencer.cs:734-741` claim that the Humanoid +motion table ships TurnRight/TurnLeft with `HasOmega` cleared +(forcing the convention fallback). The constant has 3 corroborating +sources but the actual dat content was never dumped. + +**Acceptance:** Set a cdb breakpoint on `CSequence::set_omega` +(acclient_2013_pseudo_c.txt — find exact symbol address) while +holding A or D in a retail client. Capture the `omega.z` argument +value. If `±π/2` (or numerically identical via the MotionData +formula), close as confirmed. If different, file as a regression ++ fix the constant. + +**Estimated scope:** 30 min cdb session + 1 commit if confirmed, ++ small fix if different. Not blocking M1. +``` + +- [ ] **Step 2: Commit all 3 issue entries in one docs commit** + +```bash +git add docs/ISSUES.md +git commit -m "docs: file #70 (triangle apex/size UX), #71 (picker Stage B), #72 (cdb omega.z probe) + +Deferred follow-ups from the 2026-05-16 retail-faithfulness audit +(docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md)." +``` + +--- + +## Self-review checklist + +**Spec coverage:** +- Fix #1 (rotation TurnRateFor + 1.5× run): Task A1 (helper + tests), A2 (keyboard), A3 (auto-walk). ✅ +- Fix #4 (gate `!= 0`): Task A5 step 1. ✅ +- Fix #5 (useability fallback probe): Task A4 (flag), A5 step 2 (log lines). ✅ +- Fix #2 (per-tick diff-driven AP): Tasks B1 + B2 + B3. ✅ +- Fix #6 (delete 4 workarounds): Task B4 (TinyMargin), B5 (handler + SendAutonomousPositionNow), B6 (isRetryAfterArrival). ✅ +- Fix #3 Stage A (sphere picker): Tasks B7 + B8. ✅ +- Fix #7 (trim EntityHeightFor): Task B9. ✅ +- Fix #8 (delete IsTallSceneryGuid): Task B10. ✅ +- Deferred issues (triangle/Stage B/cdb): Tasks DF1, DF2, DF3. ✅ + +**Placeholder scan:** No "TBD" / "implement later" / vague handwaves. Every code step has actual code. + +**Type consistency:** +- `TurnRateFor(bool running)` defined Task A1, called Task A2 + A3. ✅ +- `NotePositionSent(Vector3, uint, float)` defined B1, called B3. ✅ +- `SimTimeSeconds` accessor defined B1, read B3. ✅ +- `OnAutoWalkArrivedSendDeferredAction()` defined Task B6 Step 3, subscribed in Task B6 Step 4. ✅ +- `WorldPicker.Pick(...sphereForEntity...)` defined Task B7, called Task B8. ✅ +- `TryGetEntitySelectionSphere` referenced in Task B8 — already exists in `GameWindow.cs` at line ~9605 per audit. ✅ +- `ApproxPositionEqual` defined Task B2 Step 3, called Task B2 Step 2. ✅ + +--- + +## Execution handoff + +Plan saved to `docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — Dispatch fresh subagent per task; review between tasks; fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans; batch with checkpoints for review. + +**Which approach?** diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index ffdcd95..5a126b8 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 32e74b5..457ac11 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; } + /// + /// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click + /// "Pick Up." Distinct from 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. + /// + /// + /// 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. + /// + /// + /// + /// Null-useability fallback: same as + /// — 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. + /// + /// + 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) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 95cd927..540cac0 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -120,4 +120,25 @@ public static class PhysicsDiagnostics /// public static bool ProbeAutoWalkEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1"; + + /// + /// 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. + /// + /// + /// 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". + /// + /// + /// Toggle via env var ACDREAM_PROBE_USEABILITY_FALLBACK=1 + /// or DebugPanel checkbox. + /// + public static bool ProbeUseabilityFallbackEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1"; } diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 90a0388..bc1e6d5 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -76,6 +76,42 @@ public static class RemoteMoveToDriver /// public const float TurnRateRadPerSec = MathF.PI / 2.0f; + /// + /// Retail base turn rate for the player Humanoid when turn_speed + /// scalar = 1.0. Convention default omega.z = ±π/2 rad/s + /// derived from add_motion at 0x005224b0 + the + /// HasOmega-cleared MotionData fallback documented in + /// AnimationSequencer.cs:734-741. ~90°/s. + /// + public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Retail's run_turn_factor constant at 0x007c8914 + /// (PDB-named). CMotionInterp::apply_run_to_command + /// equivalent (decomp 0x00527be0, line 305098 of + /// acclient_2013_pseudo_c.txt) multiplies turn_speed + /// by 1.5 when HoldKey.Run is active on a TurnRight/TurnLeft + /// command. Effect: running rotation is 50 % faster than walking. + /// + public const float RunTurnFactor = 1.5f; + + /// + /// Retail-faithful local-player turn rate. + /// + /// Walking: BaseTurnRateRadPerSec ≈ 90°/s. + /// Running: BaseTurnRateRadPerSec × RunTurnFactor + /// ≈ 135°/s. + /// + /// Replaces the fixed TurnRateRadPerSec 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 + /// BaseTurnRateRadPerSec. + /// + public static float TurnRateFor(bool running) + => running ? BaseTurnRateRadPerSec * RunTurnFactor + : BaseTurnRateRadPerSec; + /// /// Float-comparison slack for the arrival predicate. With /// min_distance == 0 in a chase packet, exact equality is diff --git a/src/AcDream.Core/Ui/RetailMessages.cs b/src/AcDream.Core/Ui/RetailMessages.cs new file mode 100644 index 0000000..5689705 --- /dev/null +++ b/src/AcDream.Core/Ui/RetailMessages.cs @@ -0,0 +1,105 @@ +namespace AcDream.Core.Ui; + +/// +/// 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. +/// +/// +/// String text is byte-identical with retail. Each helper cites the +/// retail DAT data address + the runtime use site in the named decomp +/// at docs/research/named-retail/acclient_2013_pseudo_c.txt. +/// +/// +/// +/// Pattern mirrors — typed port of a +/// retail-UI primitive (gmRadarUI::GetBlipColor at +/// 0x004d76f0). Add new strings here as we encounter them. +/// +/// +/// +/// 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. +/// +/// +public static class RetailMessages +{ + /// + /// Retail: "The %s cannot be used". + /// Data: 0x007e2a70 (line 1033115). Runtime sprintf at + /// 0x00588ea4 (line 403095) inside ItemHolder::UseObject's + /// IsUseable==0 fallthrough branch. Shown when the player triggers + /// Use on an entity whose useability is USEABLE_UNDEF/USEABLE_NO. + /// + public static string CannotBeUsed(string entityName) + => $"The {entityName} cannot be used"; + + /// + /// Retail: "The %s can't be picked up!". + /// Runtime sprintf at 0x00587353 (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. + /// + public static string CantBePickedUp(string entityName) + => $"The {entityName} can't be picked up!"; + + /// + /// Retail: "You cannot pick up creatures!". + /// Data: 0x007e22b4 (line 1033034). Runtime use at + /// 0x005871f4 (line 401642) inside the same pickup-flow + /// handler. Shown when the player triggers a pickup on a Creature + /// ItemType (NPCs, monsters, other players). + /// + public const string CannotPickUpCreatures = "You cannot pick up creatures!"; + + /// + /// Retail: "Cannot be used with %s". + /// Data: 0x007cc834 (line 1024669). Runtime sprintf at + /// 0x0055ee0e (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 %s + /// is the TARGET entity name. No call site yet — wired in when + /// the two-target Use flow ships. + /// + public static string CannotBeUsedWith(string targetName) + => $"Cannot be used with {targetName}"; + + /// + /// Retail: "The %s cannot be picked up!". FORMAL variant. + /// Data: 0x007e227c (line 1033033). Runtime sprintf at + /// 0x00587264 (line 401623). Distinct from + /// — 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). + /// + public static string CannotBePickedUp(string entityName) + => $"The {entityName} cannot be picked up!"; + + /// + /// Retail: "The %s cannot be used while on a hook, use the + /// '@house hooks on' command to make the hook openable.\n". + /// Data: 0x007d1f68 (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. + /// + 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"; + + /// + /// Retail: "The %s cannot be used while on a hook and only + /// the owner may open the hook.\n". + /// Data: 0x007d5f30 (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. + /// + 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"; +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 64f9566..1e9ce10 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index 39182cb..659452a 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -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); + } } diff --git a/tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs b/tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs new file mode 100644 index 0000000..e161719 --- /dev/null +++ b/tests/AcDream.Core.Tests/Ui/RetailMessagesTests.cs @@ -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); + } +}