From 6e0e29752808d36d46ee4080b19a79aeb6a530b7 Mon Sep 17 00:00:00 2001 From: skulitom Date: Thu, 15 Oct 2026 23:43:36 +0100 Subject: [PATCH 1/4] Fix: a refused gamepad_set leaves part of its change for the next report (B-041) GamepadManager.Apply set each button, axis and trigger as it read them. The pads run with AutoSubmitReport off, so when a later name was unknown the call threw before SubmitReport and the earlier changes stayed in the pending report, for the agent's next gamepad_set or gamepad_tap to send. Every name is now resolved first; the changes are set and submitted together. A trigger passed as a button got "Unknown gamepad button 'lt'. Use ... lt or rt."; it is now told it is a trigger. gamepad_tap recognises the trigger names the triggers take (l2, r2). The stand-in pad in the quick checks now keeps what was set until a report sends it, and the new check "gamepad set all or nothing" covers all three. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ src/Anode/Cli/GamepadChecks.cs | 69 +++++++++++++++++++++--- src/Anode/Cli/SelfTest.cs | 1 + src/Anode/Core/Gamepad/GamepadManager.cs | 67 +++++++++++++---------- 4 files changed, 111 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c109c2..839f211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,14 @@ success; it is now refused, and the plus key is `plus`. `seat_key`, `anode key` and the daemon check the chord before anything reaches the seat, and a `vk` code outside `vk01` to `vkFE` is refused. +- **A refused `gamepad_set` changes nothing.** Anode set each button, stick and trigger of a + `gamepad_set` as it read them, so when a later name was unknown (`dpad_up`, say) the call + failed but what came before it stayed in the controller's next report: the agent's following + `gamepad_set` or `gamepad_tap` sent it, and the game saw A held that the agent thought it never + pressed. Every name is now checked before anything is set. A trigger passed as a button + (`gamepad_set` buttons `{"lt": true}`, or `anode gamepad hold lt`) was told "Unknown gamepad + button 'lt'. Use … lt or rt."; it now hears that `lt` is a trigger, to tap or to set under + `triggers`. `gamepad_tap` also takes `l2` and `r2`, as `triggers` does. - **`anode quit` ends Anode while the sign-in dialog is open.** With the credential dialog of `anode start --sign-in` waiting for an answer, `quit` stopped the seat but Anode kept running, and the CLI gave up after 60 seconds with "Anode accepted quit but is still running". Windows' dialog diff --git a/src/Anode/Cli/GamepadChecks.cs b/src/Anode/Cli/GamepadChecks.cs index 815427c..9c75eb9 100644 --- a/src/Anode/Cli/GamepadChecks.cs +++ b/src/Anode/Cli/GamepadChecks.cs @@ -177,6 +177,46 @@ public static string Attach() return "attach jails the controller before it exists, checks from the desktop, unplugs one the desktop can open, and reports a machine-wide one"; } + /// B-041: gamepad_set sends all it is given or, refused, nothing; a trigger named as a button is told how to set it. + public static string SetAllOrNothing() + { + var pad = new FakePad(null, Pad1); + using var pads = new GamepadManager(() => pad); + pads.Attach(0); + foreach (var refused in new[] + { + new JsonObject { ["buttons"] = new JsonObject { ["a"] = true, ["dpad_up"] = true } }, + new JsonObject { ["buttons"] = new JsonObject { ["a"] = true }, ["axes"] = new JsonObject { ["lx"] = 1, ["lz"] = 1 } }, + new JsonObject { ["axes"] = new JsonObject { ["lx"] = 1 }, ["triggers"] = new JsonObject { ["rt"] = 1, ["zt"] = 1 } }, + new JsonObject { ["buttons"] = new JsonObject { ["a"] = true, ["lt"] = true } } + }) + { + Require(Throws(() => pads.Apply(0, refused), (refused["buttons"] as JsonObject)?.ContainsKey("lt") == true ? "is a trigger, not a button" : "Unknown") + && !pad.Unsent, "a refused gamepad_set left changes for the next report to send: " + refused.ToJsonString()); + } + Require(Throws(() => GamepadManager.ResolveButton("dpad_up"), "up, down, left, right."), + "the unknown-button error still offers lt or rt as buttons"); + + int reports = pad.Reports; + pads.Apply(0, new JsonObject { ["buttons"] = new JsonObject { ["b"] = true } }); + Require(pad.Sent(Xbox360Button.B) is true && pad.Sent(Xbox360Button.A) is null && pad.Sent(Xbox360Axis.LeftThumbX) is null + && pad.Sent(Xbox360Slider.RightTrigger) is null && pad.Reports == reports + 1, + "the next gamepad_set sent a refused call's changes, or not its own"); + var applied = pads.Apply(0, new JsonObject + { + ["buttons"] = new JsonObject { ["a"] = true }, ["axes"] = new JsonObject { ["lx"] = -1 }, ["triggers"] = new JsonObject { ["rt"] = 1 } + }); + Require((applied["applied"] as JsonArray)?.Count == 3 && pad.Sent(Xbox360Button.A) is true && pad.Sent(Xbox360Button.B) is true + && pad.Sent(Xbox360Axis.LeftThumbX) is (short)-32768 && pad.Sent(Xbox360Slider.RightTrigger) is (byte)255 && !pad.Unsent, + "a valid gamepad_set did not send all it was given in one report: " + applied.ToJsonString()); + + // Tap takes the names the triggers take, and lets go. + pads.Tap(0, "l2", 1); + Require(pad.Sent(Xbox360Slider.LeftTrigger) is (byte)0 && pad.Reports == reports + 4, "tapping l2 did not pull and release the left trigger"); + return "a set refused for an unknown button, axis or trigger, or a trigger named as a button, leaves nothing for the next report; a valid one sends " + + "all of it at once; tap takes l2; no controller plugged in"; + } + /// /// The full self-test's live check, on the desktop: plugs in a controller jailed to an unused session and /// checks that XInput and HID opens from this session are refused. It sends no input and removes its @@ -279,12 +319,24 @@ private sealed class FakeHidHide : IHidHide public void Dispose() { } } - /// A controller that appears in the fake tree, with its devices, when it connects. + /// + /// A controller that appears in the fake tree, with its devices, when it connects. Like ViGEm's with AutoSubmitReport + /// off, it keeps what was set until its next report sends it. + /// private sealed class FakePad(FakeTree? tree, string id, params string[] devices) : IXbox360Controller { private byte _leftTrigger, _rightTrigger; private short _leftX, _leftY, _rightX, _rightY; private ushort _buttons; + // ViGEm compares its controls by index alone, so an axis and a trigger can be equal; the type tells them apart. + private readonly Dictionary<(Type, object), object> _report = new(), _sent = new(); + private static (Type, object) Key(object control) => (control.GetType(), control); + + /// A button, axis or trigger as the last report sent it; null when no report has set it. + public object? Sent(object control) => _sent.GetValueOrDefault(Key(control)); + /// Whether something set since the last report waits for the next one. + public bool Unsent => _report.Count != _sent.Count || _report.Any(entry => !Equals(_sent.GetValueOrDefault(entry.Key), entry.Value)); + public int Reports { get; private set; } public bool Disconnected { get; private set; } public int UserIndex => 0; @@ -307,15 +359,20 @@ public void Disconnect() Disconnected = true; tree?.Pads.RemoveAll(pad => pad.Id == id); } - public void SetButtonState(Xbox360Button button, bool pressed) { } - public void SetAxisValue(Xbox360Axis axis, short value) { } - public void SetSliderValue(Xbox360Slider slider, byte value) { } + public void SetButtonState(Xbox360Button button, bool pressed) => _report[Key(button)] = pressed; + public void SetAxisValue(Xbox360Axis axis, short value) => _report[Key(axis)] = value; + public void SetSliderValue(Xbox360Slider slider, byte value) => _report[Key(slider)] = value; public void SetButtonState(int index, bool pressed) { } public void SetAxisValue(int index, short value) { } public void SetSliderValue(int index, byte value) { } public void SetButtonsFull(ushort buttons) => _buttons = buttons; - public void ResetReport() { } - public void SubmitReport() { } + public void ResetReport() => _report.Clear(); + public void SubmitReport() + { + _sent.Clear(); + foreach (var (control, value) in _report) _sent[control] = value; + Reports++; + } } private static bool Throws(Action action, string words) where T : Exception diff --git a/src/Anode/Cli/SelfTest.cs b/src/Anode/Cli/SelfTest.cs index cefa9a9..b5561cf 100644 --- a/src/Anode/Cli/SelfTest.cs +++ b/src/Anode/Cli/SelfTest.cs @@ -69,6 +69,7 @@ private static void Remove(string path) Check(results, "controller isolation", GamepadChecks.Lists); Check(results, "isolated controller attach", GamepadChecks.Attach); + Check(results, "gamepad set all or nothing", GamepadChecks.SetAllOrNothing); Check(results, "CLI help routing", CliChecks.HelpRouting); Check(results, "CLI argument checks", CliChecks.Arguments); Check(results, "CLI help and messages", CliChecks.HelpText); diff --git a/src/Anode/Core/Gamepad/GamepadManager.cs b/src/Anode/Core/Gamepad/GamepadManager.cs index b0ec862..dfb98e0 100644 --- a/src/Anode/Core/Gamepad/GamepadManager.cs +++ b/src/Anode/Core/Gamepad/GamepadManager.cs @@ -185,40 +185,47 @@ public JsonObject Apply(int slot, JsonObject spec) { var pad = Require(slot); var applied = new JsonArray(); + // Every name is checked before anything is set. The pad sends whatever was set with its next report, so a call + // refused halfway would leave its first changes for the agent's next set or tap to send. + var changes = new List(); - lock (_gate) + if (spec.Obj("buttons") is { } buttons) { - if (spec.Obj("buttons") is { } buttons) + foreach (var (name, value) in buttons) { - foreach (var (name, value) in buttons) - { - bool pressed = value is not null && (value.GetValueKind() == System.Text.Json.JsonValueKind.True - || (bool.TryParse(value.ToString(), out bool parsed) && parsed)); - pad.SetButtonState(ResolveButton(name), pressed); - applied.Add($"{name}={pressed}"); - } + bool pressed = value is not null && (value.GetValueKind() == System.Text.Json.JsonValueKind.True + || (bool.TryParse(value.ToString(), out bool parsed) && parsed)); + var button = ResolveButton(name); + changes.Add(() => pad.SetButtonState(button, pressed)); + applied.Add($"{name}={pressed}"); } + } - if (spec.Obj("axes") is { } axes) + if (spec.Obj("axes") is { } axes) + { + foreach (var (name, value) in axes) { - foreach (var (name, value) in axes) - { - double magnitude = ReadNumber(value); - pad.SetAxisValue(ResolveAxis(name), ToAxis(magnitude)); - applied.Add($"{name}={magnitude:0.###}"); - } + double magnitude = ReadNumber(value); + var axis = ResolveAxis(name); + changes.Add(() => pad.SetAxisValue(axis, ToAxis(magnitude))); + applied.Add($"{name}={magnitude:0.###}"); } + } - if (spec.Obj("triggers") is { } triggers) + if (spec.Obj("triggers") is { } triggers) + { + foreach (var (name, value) in triggers) { - foreach (var (name, value) in triggers) - { - double magnitude = ReadNumber(value); - pad.SetSliderValue(ResolveTrigger(name), ToTrigger(magnitude)); - applied.Add($"{name}={magnitude:0.###}"); - } + double magnitude = ReadNumber(value); + var trigger = ResolveTrigger(name); + changes.Add(() => pad.SetSliderValue(trigger, ToTrigger(magnitude))); + applied.Add($"{name}={magnitude:0.###}"); } + } + lock (_gate) + { + foreach (var change in changes) change(); pad.SubmitReport(); } @@ -231,7 +238,7 @@ public JsonObject Tap(int slot, string name, int milliseconds) var pad = Require(slot); milliseconds = Math.Clamp(milliseconds, 1, 10_000); - bool isTrigger = name.ToLowerInvariant() is "lt" or "rt" or "lefttrigger" or "righttrigger"; + bool isTrigger = TriggerNamed(name) is not null; lock (_gate) { @@ -314,8 +321,9 @@ private static byte ToTrigger(double value) => "down" or "dpaddown" => Xbox360Button.Down, "left" or "dpadleft" => Xbox360Button.Left, "right" or "dpadright" => Xbox360Button.Right, - _ => throw new ArgumentException( - $"Unknown gamepad button '{name}'. Use a, b, x, y, lb, rb, back, start, guide, ls, rs, up, down, left, right, lt or rt.") + _ => throw new ArgumentException(TriggerNamed(name) is null + ? $"Unknown gamepad button '{name}'. Use a, b, x, y, lb, rb, back, start, guide, ls, rs, up, down, left, right." + : $"'{name}' is a trigger, not a button: tap it, or set it from 0 to 1 under triggers (gamepad_set).") }; public static Xbox360Axis ResolveAxis(string name) => name.Trim().ToLowerInvariant() switch @@ -327,11 +335,14 @@ private static byte ToTrigger(double value) => _ => throw new ArgumentException($"Unknown gamepad axis '{name}'. Use lx, ly, rx or ry.") }; - public static Xbox360Slider ResolveTrigger(string name) => name.Trim().ToLowerInvariant() switch + public static Xbox360Slider ResolveTrigger(string name) => + TriggerNamed(name) ?? throw new ArgumentException($"Unknown trigger '{name}'. Use lt or rt."); + + private static Xbox360Slider? TriggerNamed(string name) => name.Trim().ToLowerInvariant() switch { "lt" or "lefttrigger" or "l2" => Xbox360Slider.LeftTrigger, "rt" or "righttrigger" or "r2" => Xbox360Slider.RightTrigger, - _ => throw new ArgumentException($"Unknown trigger '{name}'. Use lt or rt.") + _ => null }; public void Dispose() From 714100f3f1f0cdde9ec403c24cd413ceb1e33c7b Mon Sep 17 00:00:00 2001 From: skulitom Date: Thu, 15 Oct 2026 23:56:33 +0100 Subject: [PATCH 2/4] Review: simpler stand-in pad with its report history; CLI-true trigger message From #49's second review: ViGEm's Equals(object) compares type and index, so the stand-in keys by control alone; it keeps each sent report so the check proves a tapped trigger was pulled to 255, and counts reports instead of an Unsent test that is always false after a submit; the trigger error says gamepad_set's triggers are an MCP option. Co-Authored-By: Claude Opus 5.5 --- src/Anode/Cli/GamepadChecks.cs | 31 ++++++++++-------------- src/Anode/Core/Gamepad/GamepadManager.cs | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Anode/Cli/GamepadChecks.cs b/src/Anode/Cli/GamepadChecks.cs index 9c75eb9..f9cb2c0 100644 --- a/src/Anode/Cli/GamepadChecks.cs +++ b/src/Anode/Cli/GamepadChecks.cs @@ -207,12 +207,13 @@ public static string SetAllOrNothing() ["buttons"] = new JsonObject { ["a"] = true }, ["axes"] = new JsonObject { ["lx"] = -1 }, ["triggers"] = new JsonObject { ["rt"] = 1 } }); Require((applied["applied"] as JsonArray)?.Count == 3 && pad.Sent(Xbox360Button.A) is true && pad.Sent(Xbox360Button.B) is true - && pad.Sent(Xbox360Axis.LeftThumbX) is (short)-32768 && pad.Sent(Xbox360Slider.RightTrigger) is (byte)255 && !pad.Unsent, + && pad.Sent(Xbox360Axis.LeftThumbX) is (short)-32768 && pad.Sent(Xbox360Slider.RightTrigger) is (byte)255 && pad.Reports == reports + 2, "a valid gamepad_set did not send all it was given in one report: " + applied.ToJsonString()); // Tap takes the names the triggers take, and lets go. pads.Tap(0, "l2", 1); - Require(pad.Sent(Xbox360Slider.LeftTrigger) is (byte)0 && pad.Reports == reports + 4, "tapping l2 did not pull and release the left trigger"); + Require(pad.Sent(Xbox360Slider.LeftTrigger, back: 1) is (byte)255 && pad.Sent(Xbox360Slider.LeftTrigger) is (byte)0 && pad.Reports == reports + 4, + "tapping l2 did not pull and release the left trigger"); return "a set refused for an unknown button, axis or trigger, or a trigger named as a button, leaves nothing for the next report; a valid one sends " + "all of it at once; tap takes l2; no controller plugged in"; } @@ -328,15 +329,14 @@ private sealed class FakePad(FakeTree? tree, string id, params string[] devices) private byte _leftTrigger, _rightTrigger; private short _leftX, _leftY, _rightX, _rightY; private ushort _buttons; - // ViGEm compares its controls by index alone, so an axis and a trigger can be equal; the type tells them apart. - private readonly Dictionary<(Type, object), object> _report = new(), _sent = new(); - private static (Type, object) Key(object control) => (control.GetType(), control); + private readonly Dictionary _report = new(); + private readonly List> _sent = new(); - /// A button, axis or trigger as the last report sent it; null when no report has set it. - public object? Sent(object control) => _sent.GetValueOrDefault(Key(control)); + /// A button, axis or trigger as a report sent it, the last one or before it; null when it didn't set it. + public object? Sent(object control, int back = 0) => _sent.Count > back ? _sent[^(back + 1)].GetValueOrDefault(control) : null; /// Whether something set since the last report waits for the next one. - public bool Unsent => _report.Count != _sent.Count || _report.Any(entry => !Equals(_sent.GetValueOrDefault(entry.Key), entry.Value)); - public int Reports { get; private set; } + public bool Unsent => _report.Count != (_sent.Count > 0 ? _sent[^1].Count : 0) || _report.Any(entry => !Equals(Sent(entry.Key), entry.Value)); + public int Reports => _sent.Count; public bool Disconnected { get; private set; } public int UserIndex => 0; @@ -359,20 +359,15 @@ public void Disconnect() Disconnected = true; tree?.Pads.RemoveAll(pad => pad.Id == id); } - public void SetButtonState(Xbox360Button button, bool pressed) => _report[Key(button)] = pressed; - public void SetAxisValue(Xbox360Axis axis, short value) => _report[Key(axis)] = value; - public void SetSliderValue(Xbox360Slider slider, byte value) => _report[Key(slider)] = value; + public void SetButtonState(Xbox360Button button, bool pressed) => _report[button] = pressed; + public void SetAxisValue(Xbox360Axis axis, short value) => _report[axis] = value; + public void SetSliderValue(Xbox360Slider slider, byte value) => _report[slider] = value; public void SetButtonState(int index, bool pressed) { } public void SetAxisValue(int index, short value) { } public void SetSliderValue(int index, byte value) { } public void SetButtonsFull(ushort buttons) => _buttons = buttons; public void ResetReport() => _report.Clear(); - public void SubmitReport() - { - _sent.Clear(); - foreach (var (control, value) in _report) _sent[control] = value; - Reports++; - } + public void SubmitReport() => _sent.Add(new Dictionary(_report)); } private static bool Throws(Action action, string words) where T : Exception diff --git a/src/Anode/Core/Gamepad/GamepadManager.cs b/src/Anode/Core/Gamepad/GamepadManager.cs index dfb98e0..29d223a 100644 --- a/src/Anode/Core/Gamepad/GamepadManager.cs +++ b/src/Anode/Core/Gamepad/GamepadManager.cs @@ -323,7 +323,7 @@ private static byte ToTrigger(double value) => "right" or "dpadright" => Xbox360Button.Right, _ => throw new ArgumentException(TriggerNamed(name) is null ? $"Unknown gamepad button '{name}'. Use a, b, x, y, lb, rb, back, start, guide, ls, rs, up, down, left, right." - : $"'{name}' is a trigger, not a button: tap it, or set it from 0 to 1 under triggers (gamepad_set).") + : $"'{name}' is a trigger, not a button: tap it, or, over MCP, set it from 0 to 1 under gamepad_set's triggers.") }; public static Xbox360Axis ResolveAxis(string name) => name.Trim().ToLowerInvariant() switch From f2ef33fc33591d2078c7328e02729cdcd0e1c607 Mon Sep 17 00:00:00 2001 From: skulitom Date: Thu, 15 Oct 2026 23:57:54 +0100 Subject: [PATCH 3/4] Review: list #49 in the 0.11.3 release notes and validation record From #49's second review: the 0.11.3 candidate's record (#47) now names this fix, its stand-in coverage limit and the 110 quick checks after #48 and #49. Co-Authored-By: Claude Opus 5.5 --- .github/RELEASE_NOTES.md | 4 ++++ docs/RELEASE-READINESS.md | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index b06506f..0db98e2 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -33,6 +33,10 @@ or commands were added; there are still 41 tools. - **`seat_key` knows the backtick key by the names agents try** (`grave`, `backtick`, `oem3`, `VK_OEM_3`) and other punctuation by plain names. `ctrl++`, which pressed ctrl alone and reported success, is refused; the plus key is `plus`. +- **A refused `gamepad_set` changes nothing.** When a later name in the call was unknown, the call + failed but the buttons and sticks before it went out with the agent's next `gamepad_set` or + `gamepad_tap`. Every name is now checked first. A trigger passed as a button is told it is a + trigger, and `gamepad_tap` also takes `l2` and `r2`. - **`anode quit` ends Anode while the sign-in dialog is open,** instead of leaving it running and giving up after 60 seconds. diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index a2368c8..05a4f85 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -66,7 +66,7 @@ live check) in CI and on any PC. ## 0.11.3 release validation **Scope:** everything merged since 0.11.2: -- fixes: #34, #36 to #39, #41, #42, #44 and #48; +- fixes: #34, #36 to #39, #41, #42, #44, #48 and #49; - documentation: #45 (TROUBLESHOOTING's typing advice); - licensing: #19 (the Anode Business licence terms, first released here; Anode stays MIT) and #40 (the README's Business link); @@ -97,17 +97,17 @@ release does not include it. - Windows CI on the candidate repeats the build, packaging, quick suite, installation, distribution and documentation checks, and runs the environment self-test on a GitHub-hosted Windows runner. -- Fixes merged after the candidate was first recorded (#48): `scripts\build.ps1 -QuickTest` passed - with each (**109 quick self-tests**), with the documentation check, and so did Windows CI. The - package steps above were not repeated by hand. +- Fixes merged after the candidate was first recorded (#48, #49): `scripts\build.ps1 -QuickTest` + passed with each (**110 quick self-tests** after both), with the documentation check, and so did + Windows CI. The package steps above were not repeated by hand. ### Coverage limits and known issues - The live check doesn't exercise these fixes, which rest on their regression checks with stand-ins: Reconnect after a password sign-in (#42), `quit` with the sign-in dialog open (#34), - a seat stopped during a display change (#44), a Reconnect refused while a seat runs (#48), MCP - input waiting for a look after a lease is taken (#38), `raise` on a real UWP window (#37) and - non-English Windows date formats (#41). + a seat stopped during a display change (#44), a Reconnect refused while a seat runs (#48), a + `gamepad_set` refused partway (#49), MCP input waiting for a look after a lease is taken (#38), + `raise` on a real UWP window (#37) and non-English Windows date formats (#41). - The environment self-test ran on CI's hosted runner, not inside a seat. Audio, the virtual gamepad, Android, the foreground pointer phases and the Claude Desktop bundle test are not part of the live check. From fcb352301fd5e9173157d95b415bd8258552aee7 Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 16 Oct 2026 00:03:14 +0100 Subject: [PATCH 4/4] Review: the release note names triggers among what a refused set left behind Co-Authored-By: Claude Opus 5.5 --- .github/RELEASE_NOTES.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/RELEASE_NOTES.md b/.github/RELEASE_NOTES.md index 0db98e2..514173f 100644 --- a/.github/RELEASE_NOTES.md +++ b/.github/RELEASE_NOTES.md @@ -34,9 +34,9 @@ or commands were added; there are still 41 tools. `VK_OEM_3`) and other punctuation by plain names. `ctrl++`, which pressed ctrl alone and reported success, is refused; the plus key is `plus`. - **A refused `gamepad_set` changes nothing.** When a later name in the call was unknown, the call - failed but the buttons and sticks before it went out with the agent's next `gamepad_set` or - `gamepad_tap`. Every name is now checked first. A trigger passed as a button is told it is a - trigger, and `gamepad_tap` also takes `l2` and `r2`. + failed but the buttons, sticks and triggers before it went out with the agent's next + `gamepad_set` or `gamepad_tap`. Every name is now checked first. A trigger passed as a button is + told it is a trigger, and `gamepad_tap` also takes `l2` and `r2`. - **`anode quit` ends Anode while the sign-in dialog is open,** instead of leaving it running and giving up after 60 seconds.