Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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<hex>` 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
Expand Down
14 changes: 7 additions & 7 deletions docs/RELEASE-READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 58 additions & 6 deletions src/Anode/Cli/GamepadChecks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,47 @@ 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";
}

/// <summary>B-041: gamepad_set sends all it is given or, refused, nothing; a trigger named as a button is told how to set it.</summary>
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<ArgumentException>(() => 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<ArgumentException>(() => 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.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, 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";
}

/// <summary>
/// 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
Expand Down Expand Up @@ -279,12 +320,23 @@ private sealed class FakeHidHide : IHidHide
public void Dispose() { }
}

/// <summary>A controller that appears in the fake tree, with its devices, when it connects.</summary>
/// <summary>
/// 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.
/// </summary>
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;
private readonly Dictionary<object, object> _report = new();
private readonly List<Dictionary<object, object>> _sent = new();

/// <summary>A button, axis or trigger as a report sent it, the last one or <paramref name="back"/> before it; null when it didn't set it.</summary>
public object? Sent(object control, int back = 0) => _sent.Count > back ? _sent[^(back + 1)].GetValueOrDefault(control) : null;
/// <summary>Whether something set since the last report waits for the next one.</summary>
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;
Expand All @@ -307,15 +359,15 @@ 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[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() { }
public void SubmitReport() { }
public void ResetReport() => _report.Clear();
public void SubmitReport() => _sent.Add(new Dictionary<object, object>(_report));
}

private static bool Throws<T>(Action action, string words) where T : Exception
Expand Down
1 change: 1 addition & 0 deletions src/Anode/Cli/SelfTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
67 changes: 39 additions & 28 deletions src/Anode/Core/Gamepad/GamepadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action>();

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();
}

Expand All @@ -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)
{
Expand Down Expand Up @@ -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, over MCP, set it from 0 to 1 under gamepad_set's triggers.")
};

public static Xbox360Axis ResolveAxis(string name) => name.Trim().ToLowerInvariant() switch
Expand All @@ -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()
Expand Down
Loading