Retires divergences flagged in the 2026-05-16 faithfulness audit: 1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat with a diff-driven model gated on `Contact && OnWalkable` (acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on position or cell change while grounded on walkable, plus a 1 sec heartbeat; suppressed entirely airborne. PlayerMovementController exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps after each AutonomousPosition / MoveToState send — mirrors retail's shared `last_sent_position_time` between SendPositionEvent (0x006b4770) and SendMovementEvent (0x006b4680). Known divergence from retail: ours is per-frame-while-moving, retail's effective rate is ~1 Hz during smooth motion (cell/plane checks). Filed as #74, blocked by #63 — when #63 lands we revert to retail's narrower gate. 2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival) and the AP-flush before re-send (`SendAutonomousPositionNow`). The diff-driven cadence makes both obsolete. Close-range turn-first deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87 mirrors retail's CreateMoveToChain pre-callback rotation), renamed `OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send. `isRetryAfterArrival` parameter dropped. 3. Far-range Use/PickUp retry. Restored — was load-bearing, not the "redundant cleanup" the Group 2 audit thought. Issue #63 means ACE drops the first Use as too-far without re-polling on subsequent APs; the arrival re-send is what makes far-range Use complete. Logs include `(queued for arrival re-send pending #63)` to make this explicit. Removes when #63 closes. 4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection` helper shared by `WorldPicker` and `TargetIndicatorPanel`. The `Setup.SelectionSphere` projects to a screen-space square (retail anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker hit-tests the mouse pixel against the same rect the indicator draws, inflated by 8 px (`TriangleSize`). Guarantees what-you-see is what-you-click — including rect corners that were dead zones under the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired; `IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale defensive default. No defensive sphere synth — entities without a baked `SelectionSphere` are skipped, matching retail's `GfxObjUnderSelectionRay` (0x0054c740). 5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)` helper applies retail's `run_turn_factor = 1.5f` (PDB-named 0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at 0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s. Keyboard A/D + ApplyAutoWalkOverlay both use it. 6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to `useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail (ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697, UsingItem 0x367638, disable-button-state 0x198826). Added `ProbeUseabilityFallbackEnabled` diagnostic (`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the creature/BF_DOOR fallback fires for ACE-seed-DB entities with null useability. CLAUDE.md updated with the graceful-shutdown rule for relaunch: Stop-Process bypasses the logout packet, leaving ACE's session marked logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the shutdown hook runs and the logout packet reaches ACE. Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9. Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures unchanged). Visual-verified 2026-05-16: rotation rate, useability, screen-rect click area, double-click + R-key + F-key Use/PickUp at short and long range — dialogue/door/pickup fire on arrival. Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message sweep pattern), #74 (per-frame AP chattier than retail — blocked by #63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)` overload kept for back-compat; no callers in acdream proper. Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.5 KiB
C#
133 lines
4.5 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.Core.Tests.Selection;
|
|
|
|
public sealed class WorldPickerRectOverloadTests
|
|
{
|
|
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
|
|
{
|
|
var view = Matrix4x4.Identity;
|
|
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
|
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
|
|
var viewport = new Vector2(800, 600);
|
|
return (view, proj, viewport);
|
|
}
|
|
|
|
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
|
|
{
|
|
Id = serverGuid == 0u ? 1u : serverGuid,
|
|
ServerGuid = serverGuid,
|
|
SourceGfxObjOrSetupId = 0u,
|
|
Position = position,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
};
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
|
uint? picked = WorldPicker.Pick(
|
|
mouseX: 400f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { e },
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Equal(0x10001u, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
|
uint? picked = WorldPicker.Pick(
|
|
mouseX: 50f, mouseY: 50f,
|
|
view, proj, viewport,
|
|
new[] { e },
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Null(picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
|
|
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
|
uint? picked = WorldPicker.Pick(
|
|
mouseX: 400f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { far, near } /* deliberately reversed */,
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Equal(0x10001u, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_NullResolverSkipsCandidates()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var e1 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
|
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
|
|
uint? picked = WorldPicker.Pick(
|
|
mouseX: 400f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { e1, e2 },
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => x.ServerGuid == 0x10001u
|
|
? ((Vector3, float)?)null
|
|
: ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Equal(0x10002u, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_RespectsSkipServerGuid()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
|
|
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
|
|
uint? picked = WorldPicker.Pick(
|
|
mouseX: 400f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { player, npc },
|
|
skipServerGuid: 0x5000000Au,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Equal(0x10002u, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pick_RectHitTest_InflateExpandsClickableArea()
|
|
{
|
|
var (view, proj, viewport) = StdCam();
|
|
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
|
|
|
|
uint? withoutInflate = WorldPicker.Pick(
|
|
mouseX: 400f + 200f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { e },
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 0f);
|
|
Assert.Null(withoutInflate);
|
|
|
|
uint? withInflate = WorldPicker.Pick(
|
|
mouseX: 400f + 200f, mouseY: 300f,
|
|
view, proj, viewport,
|
|
new[] { e },
|
|
skipServerGuid: 0u,
|
|
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
|
|
inflatePixels: 250f);
|
|
Assert.Equal(0x10001u, withInflate);
|
|
}
|
|
}
|