feat(input): assess world objects on right click

Port SmartBox's release-completed sr_Examine gesture through the configurable SelectRight binding. Cancel camera drags at the shared retail three-pixel threshold, then reuse the canonical picker, selection pulse, and appraisal request path without inventing another wire or UI owner.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-24 05:42:28 +02:00
parent 6718ee45a0
commit 5ad32d5753
16 changed files with 394 additions and 13 deletions

View file

@ -142,6 +142,12 @@ DAT resolver prefixes it exactly once. Empty append fragments now preserve
retail's intentional blank section rows, including the blank after
Workmanship and the literal leading newline before Armor Level.
The adjacent world-input follow-up also completes retail right-click
assessment. `SelectRight` is now a release-completed configurable click with
the shared three-pixel drag threshold, then pulses, selects, and appraises the
picked world object through the same owners as the magnifying glass and E key.
RMB camera drags and empty-world releases do not issue appraisal requests.
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.cs`;
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`;
@ -162,6 +168,8 @@ animated target remains above row chrome but below text; changing selection
updates the open window without another magnifier click. Item text begins at
the top and assessed melee/missile/armor/magic items show their retail-ordered
stats and full DAT spell descriptions.
Right-clicking a visible world object opens the same examination window;
right-dragging the camera does not.
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
then its description after one retail paragraph break, without the bogus
255-item/255-container line.

View file

@ -30,7 +30,10 @@ ports the optional public shared-cooldown metadata, the authoritative
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
presentation; its connected gate passed. The existing magnifying-glass Assess
command was already retail-faithful. Slice 3's first connected gate exposed
command was already retail-faithful. World right-click assessment now follows
the configurable `SelectRight` action's retail release-completed gesture,
including drag cancellation, then reuses the canonical
picker/selection/appraisal path. Slice 3's first connected gate exposed
three deeper defects, now corrected: the response enum mislabeled ACE's
creature bit `0x0100` and dropped monster packets while retaining the busy
cursor; the imported 310 x 400 examination layout was incorrectly hosted as

View file

@ -98,6 +98,9 @@ adjacent cooldown follow-up now preserves the item's shared group/duration,
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
DAT-authored radial steps across all item-slot surfaces; its connected gate
passed. The pre-existing Assess command has been reconciled against retail.
The same slice now includes retail world right-click assessment through the
configurable `SelectRight` complete-click gesture; RMB orbit drags cancel
instead of appraising the release point.
Slice 3's first connected gate found and corrected the real remaining defects:
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
appraisal busy reference held, retail's examination UI had been mounted as a

View file

@ -226,6 +226,17 @@ title, and boots-layout fixtures protect the full path. The Release solution
build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests /
5 skips pass; the connected boots comparison is the remaining visual gate.
The world right-click follow-up ports
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` and the
`sr_Examine` branch of `RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`.
The configurable `SelectRight` binding now completes on release, cancels when
pointer travel crosses the retail-observed three-pixel drag threshold, and
routes through the existing world picker, lighting pulse, canonical
`SelectionState`, and appraisal request owner. Empty world space remains a
no-op, right-drag camera orbit does not appraise its release point, and the
independent configurable `SelectionExamine` action now reaches the same
request/target-mode path.
## Slice 1 — spell-bar overflow arrows
### Retail oracle

View file

@ -190,6 +190,37 @@ procedural arrow. The indicator is persistent selection presentation. Retail
also has a separate, short-lived click confirmation owned by SmartBox; it is not
implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
## Right-click selection and examination
`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820`
```text
on right mouse up:
if mouse movement owns the gesture:
finish mouse movement and do not examine
else if instant mouse-look owns the gesture:
leave mouse-look and do not examine
else if no higher-priority SmartBox search is pending:
searchReason = sr_Examine
FindObject(releaseX, releaseY)
```
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`
```text
if a valid object was found for sr_Examine:
begin the normal SmartBox lighting pulse
SetSelectedObject(foundObjectId)
ClientUISystem.ExamineObject(foundObjectId)
else:
do nothing
```
The action is release-completed rather than mouse-down-completed so an RMB
camera drag does not appraise the object under the release point. Acdream maps
the configurable `SelectRight` action to the same complete-click gesture and
keeps `SelectionExamine` independently configurable (retail default E).
## SmartBox click lighting pulse
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`

View file

@ -262,7 +262,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
return;
if (activation is not ActivationType.Press
and not ActivationType.DoubleClick)
and not ActivationType.DoubleClick
and not ActivationType.Click)
{
return;
}

View file

@ -73,9 +73,16 @@ internal sealed class SelectionInteractionController
case InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
return true;
case InputAction.SelectRight:
PickSelectAndExamine();
return true;
case InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
return true;
case InputAction.SelectionExamine:
_items.ExamineSelectedOrEnterMode(
_selection.SelectedObjectId ?? 0u);
return true;
case InputAction.UseSelected:
UseCurrentSelection();
return true;
@ -181,6 +188,25 @@ internal sealed class SelectionInteractionController
});
}
/// <summary>
/// Retail SmartBox right-click path:
/// <c>UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820</c> chooses
/// <c>sr_Examine</c>, then
/// <c>RecvNotice_SmartBoxObjectFound @ 0x004E5AD0</c> pulses, selects,
/// and calls <c>ClientUISystem::ExamineObject</c>. Empty space is a no-op,
/// and this path does not consume a left-click target mode.
/// </summary>
public void PickSelectAndExamine()
{
uint? picked = _query.PickAtCursor(includeSelf: false);
if (picked is not uint guid)
return;
_query.BeginLightingPulse(guid);
_selection.Select(guid, SelectionChangeSource.World);
_items.ExamineSelectedOrEnterMode(guid);
}
public void UseCurrentSelection()
{
if (_selection.SelectedObjectId is not uint selected)

View file

@ -21,6 +21,12 @@ public enum ActivationType
Hold,
/// <summary>Mouse-button double-click within retail's chord window.</summary>
DoubleClick,
/// <summary>
/// A complete pointer click: button down and up without crossing the
/// retail-observed three-pixel drag threshold. Keyboard chords bound to a
/// click action fire on key-down, preserving configurable action bindings.
/// </summary>
Click,
/// <summary>Mouse axis or other analog input. Reserved for future
/// rebindable mouse-look — K.1a does not emit this.</summary>
Analog,

View file

@ -39,7 +39,8 @@ public sealed class InputDispatcher : IDisposable
private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
private readonly HashSet<InputAction> _automationHeldActions = new();
private readonly bool[] _sourceAttached = new bool[5];
private readonly Dictionary<MouseButton, float> _mouseClickTravel = new();
private readonly bool[] _sourceAttached = new bool[6];
private bool _attachStarted;
private int _disposeRequested;
private int _active;
@ -51,13 +52,15 @@ public sealed class InputDispatcher : IDisposable
private MouseButton? _lastMouseDownButton;
private long _lastMouseDownTickMs;
private const long DoubleClickThresholdMs = 500;
private const float ClickDragThresholdPixels = 3f;
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
/// chord is reported via this callback INSTEAD of firing actions. Esc
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
private Action<KeyChord>? _captureCallback;
/// <summary>Fires every time a binding matches a press / release / hold tick.
/// <summary>Fires every time a binding matches a press, release, hold,
/// complete click, or double-click.
/// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired;
@ -110,6 +113,8 @@ public sealed class InputDispatcher : IDisposable
_sourceAttached[3] = true;
_mouse.MouseUp += OnMouseUp;
_sourceAttached[4] = true;
_mouse.MouseMove += OnMouseMove;
_sourceAttached[5] = true;
_mouse.Scroll += OnScroll;
Volatile.Write(ref _active, 1);
}
@ -140,6 +145,7 @@ public sealed class InputDispatcher : IDisposable
_captureCallback = null;
_heldHoldChords.Clear();
_automationHeldActions.Clear();
_mouseClickTravel.Clear();
}
public void Dispose()
@ -479,6 +485,9 @@ public sealed class InputDispatcher : IDisposable
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var click = FindActive(chord, ActivationType.Click);
if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
@ -532,6 +541,7 @@ public sealed class InputDispatcher : IDisposable
private void OnMouseDown(MouseButton button, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
_mouseClickTravel.Remove(button);
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
@ -545,6 +555,9 @@ public sealed class InputDispatcher : IDisposable
_heldHoldChords.Add(chord);
}
if (FindActive(chord, ActivationType.Click) is not null)
_mouseClickTravel[button] = 0f;
// Double-click recognition. Same button within DoubleClickThresholdMs
// -> additionally fire ActivationType.DoubleClick for any matching
// binding. Press has already fired for the second click (same as a
@ -568,6 +581,7 @@ public sealed class InputDispatcher : IDisposable
{
if (Volatile.Read(ref _active) == 0) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
@ -585,6 +599,35 @@ public sealed class InputDispatcher : IDisposable
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
if (wasClickCandidate
&& !_mouse.WantCaptureMouse
&& travel <= ClickDragThresholdPixels
&& FindActive(chord, ActivationType.Click) is { } click)
{
Fired?.Invoke(click.Action, ActivationType.Click);
}
}
private void OnMouseMove(float dx, float dy)
{
if (Volatile.Read(ref _active) == 0 || _mouseClickTravel.Count == 0)
return;
if (_mouse.WantCaptureMouse)
{
_mouseClickTravel.Clear();
return;
}
float distance = MathF.Sqrt((dx * dx) + (dy * dy));
foreach (MouseButton button in _mouseClickTravel.Keys.ToArray())
{
if (_mouse.IsHeld(button))
_mouseClickTravel[button] += distance;
else
_mouseClickTravel.Remove(button);
}
}
private void OnScroll(float delta)
@ -632,7 +675,8 @@ public sealed class InputDispatcher : IDisposable
case 1: _keyboard.KeyUp -= OnKeyUp; break;
case 2: _mouse.MouseDown -= OnMouseDown; break;
case 3: _mouse.MouseUp -= OnMouseUp; break;
case 4: _mouse.Scroll -= OnScroll; break;
case 4: _mouse.MouseMove -= OnMouseMove; break;
case 5: _mouse.Scroll -= OnScroll; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_sourceAttached[index] = false;

View file

@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 4;
private const int CurrentSchemaVersion = 5;
private readonly List<Binding> _bindings = new();
@ -324,7 +324,8 @@ public sealed class KeyBindings
InputAction.SelectLeft));
b.Add(new(
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
InputAction.SelectRight));
InputAction.SelectRight,
ActivationType.Click));
b.Add(new(
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
InputAction.SelectMid));
@ -367,9 +368,9 @@ public sealed class KeyBindings
InputAction.AcdreamToggleFlyMode));
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
// SelectRight Press binding above — Press fires on click,
// AcdreamRmbOrbitHold fires on hold/release transitions so the
// chase camera can free-orbit while the user drags the mouse.
// SelectRight Click binding above resolves only after a stationary
// release. AcdreamRmbOrbitHold fires on hold/release transitions so
// the chase camera can free-orbit while the user drags the mouse.
// Without this, RMB-orbit silently broke when K.1c flipped the
// default keymap from AcdreamCurrentDefaults to RetailDefaults.
b.Add(new(
@ -435,6 +436,7 @@ public sealed class KeyBindings
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
activation = MigrateCombatAttackActivation(version, action, activation);
activation = MigrateSelectRightActivation(version, action, activation);
InputScope scope = defaults.ForAction(action)
.Select(binding => binding.Scope)
.DefaultIfEmpty(InputScope.Game)
@ -562,6 +564,21 @@ public sealed class KeyBindings
: activation;
}
/// <summary>
/// Schema v4 fired SelectRight on mouse-down. Retail's SmartBox resolves
/// right-click examination on mouse-up only when the pointer did not turn
/// into a drag, so preserve the user's chord while upgrading its gesture.
/// </summary>
private static ActivationType MigrateSelectRightActivation(
int version,
InputAction action,
ActivationType activation)
=> version < 5
&& action == InputAction.SelectRight
&& activation == ActivationType.Press
? ActivationType.Click
: activation;
private static ModifierMask ParseModifiers(JsonElement bindingEl)
{
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;

View file

@ -70,6 +70,21 @@ public sealed class GameplayInputActionRouterTests
harness.Targets.Calls);
}
[Fact]
public void Click_PassesGateToSelection()
{
var harness = new Harness("selection");
harness.Router.Attach();
harness.Actions.Raise(
InputAction.SelectRight,
ActivationType.Click);
Assert.Equal(
["pointer", "combat", "retained", "selection"],
harness.Targets.Calls);
}
[Fact]
public void Attach_SeedsAndTracksCombatScopes()
{

View file

@ -223,6 +223,65 @@ public sealed class SelectionInteractionControllerTests
Assert.Null(h.Selection.SelectedObjectId);
}
[Fact]
public void RightClickPulsesSelectsAndExaminesPickedWorldObject()
{
var h = new Harness();
h.Query.Picked = Target;
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight));
Assert.False(h.Query.LastIncludeSelf);
Assert.Equal(Target, h.Selection.SelectedObjectId);
Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
Assert.Equal(new[] { Target }, h.Examines);
}
[Fact]
public void RightClickEmptyWorldIsANoOp()
{
var h = new Harness();
Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight));
Assert.Equal(new[] { "pick" }, h.Query.Events);
Assert.Null(h.Selection.SelectedObjectId);
Assert.Empty(h.Examines);
Assert.Empty(h.Toasts);
}
[Fact]
public void RightClickExaminesWithoutConsumingLeftClickTargetMode()
{
var h = new Harness();
h.Query.Picked = Target;
h.Items.InteractionState.EnterUse();
h.Controller.HandleInputAction(InputAction.SelectRight);
Assert.Equal(InteractionModeKind.Use, h.Items.InteractionState.Current.Kind);
Assert.Equal(Target, h.Selection.SelectedObjectId);
Assert.Equal(new[] { Target }, h.Examines);
}
[Fact]
public void ExamineActionUsesSelectionOrEntersTargetMode()
{
var selected = new Harness();
selected.Selection.Select(Target, SelectionChangeSource.World);
Assert.True(selected.Controller.HandleInputAction(
InputAction.SelectionExamine));
Assert.Equal(new[] { Target }, selected.Examines);
var empty = new Harness();
Assert.True(empty.Controller.HandleInputAction(
InputAction.SelectionExamine));
Assert.Equal(
InteractionModeKind.Examine,
empty.Items.InteractionState.Current.Kind);
}
[Fact]
public void ClosestTargetInputMutatesSelectionOnce()
{

View file

@ -0,0 +1,109 @@
using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public sealed class InputDispatcherClickTests
{
[Fact]
public void MouseClickFiresOnReleaseAfterStationaryPress()
{
var (dispatcher, mouse, fired) = BuildMouse();
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired);
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
Assert.Equal(
[(InputAction.SelectRight, ActivationType.Click)],
fired);
dispatcher.Dispose();
}
[Fact]
public void MouseClickDoesNotFireAfterCrossingRetailDragThreshold()
{
var (dispatcher, mouse, fired) = BuildMouse();
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
mouse.EmitMouseMove(4f, 0f);
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired);
dispatcher.Dispose();
}
[Fact]
public void MouseClickAllowsMovementAtRetailDragThreshold()
{
var (dispatcher, mouse, fired) = BuildMouse();
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
mouse.EmitMouseMove(3f, 0f);
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
Assert.Single(fired);
dispatcher.Dispose();
}
[Fact]
public void CapturedMouseNeverStartsWorldClick()
{
var (dispatcher, mouse, fired) = BuildMouse();
mouse.WantCaptureMouse = true;
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
mouse.WantCaptureMouse = false;
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired);
dispatcher.Dispose();
}
[Fact]
public void KeyboardRebindOfClickActionFiresOnKeyDown()
{
var keyboard = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(Key.Q, ModifierMask.None),
InputAction.SelectRight,
ActivationType.Click));
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
dispatcher.Attach();
keyboard.EmitKeyDown(Key.Q, ModifierMask.None);
Assert.Equal(
[(InputAction.SelectRight, ActivationType.Click)],
fired);
dispatcher.Dispose();
}
private static (
InputDispatcher Dispatcher,
FakeMouseSource Mouse,
List<(InputAction, ActivationType)> Fired) BuildMouse()
{
var keyboard = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Right),
ModifierMask.None,
Device: 1),
InputAction.SelectRight,
ActivationType.Click));
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
dispatcher.Attach();
return (dispatcher, mouse, fired);
}
}

View file

@ -11,6 +11,7 @@ public sealed class InputDispatcherLifetimeTests
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var source = new ThrowingSources { FailAdd = failAdd };
@ -112,6 +113,7 @@ public sealed class InputDispatcherLifetimeTests
private Action<Key, ModifierMask>? _keyUp;
private Action<MouseButton, ModifierMask>? _mouseDown;
private Action<MouseButton, ModifierMask>? _mouseUp;
private Action<float, float>? _mouseMove;
private Action<float>? _scroll;
public int FailAdd { get; init; }
@ -153,9 +155,11 @@ public sealed class InputDispatcherLifetimeTests
add => Add(ref _mouseUp, value!);
remove => Remove(ref _mouseUp, value!);
}
#pragma warning disable CS0067
public event Action<float, float>? MouseMove;
#pragma warning restore CS0067
public event Action<float, float>? MouseMove
{
add => Add(ref _mouseMove, value!);
remove => Remove(ref _mouseMove, value!);
}
public event Action<float>? Scroll
{
add => Add(ref _scroll, value!);

View file

@ -258,6 +258,37 @@ public class KeyBindingsJsonTests
}
}
[Fact]
public void LoadOrDefault_migratesV4SelectRightPressBindingToClick()
{
var path = TempFile();
try
{
const string json = """
{
"version": 4,
"actions": {
"SelectRight": [
{ "key": "-1002", "device": 1 }
]
}
}
""";
File.WriteAllText(path, json);
var loaded = KeyBindings.LoadOrDefault(path);
Binding binding = Assert.Single(
loaded.ForAction(InputAction.SelectRight));
Assert.Equal((Key)(-1002), binding.Chord.Key);
Assert.Equal(ActivationType.Click, binding.Activation);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_unknown_action_name_skipped_silently()
{

View file

@ -108,6 +108,19 @@ public class KeyBindingsRetailTests
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
}
[Fact]
public void SelectRightIsAReleaseCompletedClick()
{
var bindings = KeyBindings.RetailDefaults();
Binding binding = Assert.Single(bindings.ForAction(InputAction.SelectRight));
Assert.Equal(1, binding.Chord.Device);
Assert.Equal(
InputDispatcher.MouseButtonToKey(MouseButton.Right),
binding.Chord.Key);
Assert.Equal(ActivationType.Click, binding.Activation);
}
[Theory]
[InlineData(Key.Delete, InputAction.CombatLowAttack)]
[InlineData(Key.End, InputAction.CombatMediumAttack)]