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
1 change: 1 addition & 0 deletions docs/frame-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ work relative to them instead of guessing.
| Constant | Value | Runs there |
| --- | --- | --- |
| `UpdateOrders.Diagnostics` | -20 000 | `PerformanceTracker` |
| `UpdateOrders.Input` | -10 000 | `InputAutomationConsole` (with `AddInputAutomation()`) |
| `UpdateOrders.Default` | 0 | `TimerSystem`, `UpdateSystem` |
| `UpdateOrders.Ui` | 10 000 | `UiUpdateSystem<T>` (Pixely.Ui) |
| `UpdateOrders.Maintenance` | 20 000 | `FontSystem` |
Expand Down
30 changes: 30 additions & 0 deletions docs/input-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,33 @@ input.MouseUp(MouseButton.Left, new Vector2(200, 100));
The default `ViewScope` targets the ordinary single-window application. Pass a scope explicitly for another registered window.

Automated input affects Pixely's event-derived synthetic device state. It does not move the operating-system cursor, change window focus, or modify SDL's physical/global device state.

## Driving the app from standard input

`AddInputAutomation()` also reads command lines from the process's standard input and runs each one on the frame loop at `UpdateOrders.Input`, after the frame's real events and before the game's updatables. Every command gets one reply line on standard output: `ok` or `error: <reason>` for a malformed line. Blank lines and lines starting with `#` are ignored and get no reply. Lines queued before a frame starts run in that frame; a chord written in one go usually lands in one frame but may split across two. An exception thrown by an input handler propagates out of the frame loop, the same as for real input.

| Command | Calls |
| --- | --- |
| `mouse move <x> <y>` | `MouseMoveTo` |
| `mouse moveby <dx> <dy>` | `MouseMoveBy` |
| `mouse down <button> <x> <y>` | `MouseDown` |
| `mouse up <button> <x> <y>` | `MouseUp` |
| `mouse click <button> <x> <y>` | `MouseClick` |
| `mouse wheel <dx> <dy> <x> <y>` | `MouseWheel` |
| `key down <scancode>` | `KeyDown` |
| `key up <scancode>` | `KeyUp` |
| `key press <scancode>` | `KeyPress` |
| `text <text>` | `TextInput` with the rest of the line |

`<button>` is a `MouseButton` name and `<scancode>` a `Scancode` name, both case-insensitive. Numbers use invariant culture. Commands always target the default `ViewScope`.

A tool that cannot hold the pipe open, such as an LLM agent running one shell command at a time, drives the app through a file it appends to:

```sh
: > commands.txt
tail -f commands.txt | dotnet run --project MyGame > replies.txt &
printf 'key down LeftCtrl\nkey press E\nkey up LeftCtrl\n' >> commands.txt
cat replies.txt
```

Keep logging off standard output while doing this; replies share the stream.
3 changes: 3 additions & 0 deletions src/Pixely.Core/UpdateOrders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public static class UpdateOrders
/// <summary>Frame instrumentation, before anything the frame does. Used by <c>PerformanceTracker</c>.</summary>
public const int Diagnostics = -20_000;

/// <summary>Synthetic input from outside the process, after the frame's real events and before the game reads input. Used by <c>InputAutomationConsole</c>.</summary>
public const int Input = -10_000;

/// <summary>What an updatable runs at when it does not choose. Used by <c>TimerSystem</c> and <c>UpdateSystem</c>.</summary>
public const int Default = 0;

Expand Down
103 changes: 103 additions & 0 deletions src/Pixely/Input/InputAutomationCommandInterpreter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Globalization;
using System.Numerics;

namespace Pixely.Input;

/// <summary>
/// Runs one line of the text command grammar against <see cref="IInputAutomation"/> and returns the reply line:
/// <c>ok</c>, <c>error: ...</c> for a malformed line, or null for a blank or <c>#</c> comment line that gets no reply.
/// Exceptions from input handlers propagate, the same as for real input.
/// </summary>
internal sealed class InputAutomationCommandInterpreter
{
private readonly IInputAutomation _automation;

public InputAutomationCommandInterpreter(IInputAutomation automation)
{
_automation = automation;
}

public string? Execute(string line)
{
string command = line.TrimStart();
if (command.Length == 0 || command[0] == '#')
{
return null;
}

try
{
Run(command);
return "ok";
}
catch (FormatException exception)
{
return $"error: {exception.Message}";
}
}

private void Run(string command)
{
string[] words = command.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
switch (words)
{
case ["mouse", "move", string x, string y]:
_automation.MouseMoveTo(ParseVector(x, y));
break;
case ["mouse", "moveby", string dx, string dy]:
_automation.MouseMoveBy(ParseVector(dx, dy));
break;
case ["mouse", "down", string button, string x, string y]:
_automation.MouseDown(ParseEnum<MouseButton>(button), ParseVector(x, y));
break;
case ["mouse", "up", string button, string x, string y]:
_automation.MouseUp(ParseEnum<MouseButton>(button), ParseVector(x, y));
break;
case ["mouse", "click", string button, string x, string y]:
_automation.MouseClick(ParseEnum<MouseButton>(button), ParseVector(x, y));
break;
case ["mouse", "wheel", string dx, string dy, string x, string y]:
_automation.MouseWheel(ParseVector(dx, dy), ParseVector(x, y));
break;
case ["key", "down", string scancode]:
_automation.KeyDown(ParseEnum<Scancode>(scancode));
break;
case ["key", "up", string scancode]:
_automation.KeyUp(ParseEnum<Scancode>(scancode));
break;
case ["key", "press", string scancode]:
_automation.KeyPress(ParseEnum<Scancode>(scancode));
break;
case ["text", ..]:
_automation.TextInput(command["text".Length..].TrimStart());
break;
default:
throw new FormatException($"unknown command '{command}'");
}
}

private static Vector2 ParseVector(string x, string y)
{
return new Vector2(ParseFloat(x), ParseFloat(y));
}

private static float ParseFloat(string word)
{
if (!float.TryParse(word, NumberStyles.Float, CultureInfo.InvariantCulture, out float value))
{
throw new FormatException($"invalid number '{word}'");
}

return value;
}

private static TEnum ParseEnum<TEnum>(string word) where TEnum : struct, Enum
{
if (!Enum.TryParse(word, ignoreCase: true, out TEnum value) || !Enum.IsDefined(value))
{
throw new FormatException($"unknown {typeof(TEnum).Name} '{word}'");
}

return value;
}
}
55 changes: 55 additions & 0 deletions src/Pixely/Input/InputAutomationConsole.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Collections.Concurrent;

namespace Pixely.Input;

/// <summary>
/// Reads command lines from a text stream on a background thread and runs them on the frame loop, one reply line per command.
/// </summary>
internal sealed class InputAutomationConsole : IUpdatable
{
private readonly InputAutomationCommandInterpreter _interpreter;
private readonly TextReader _input;
private readonly TextWriter _output;
private readonly ConcurrentQueue<string> _pendingLines = new();
private Thread? _readerThread;

internal InputAutomationConsole(IInputAutomation automation, TextReader input, TextWriter output)
{
_interpreter = new InputAutomationCommandInterpreter(automation);
_input = input;
_output = output;
}

public int UpdateOrder => UpdateOrders.Input;

public void Update()
{
_readerThread ??= StartReader();

// Only what was queued when the frame started, so a fast writer cannot hold the frame.
int pendingCount = _pendingLines.Count;
for (int i = 0; i < pendingCount && _pendingLines.TryDequeue(out string? line); i++)
{
string? reply = _interpreter.Execute(line);
if (reply is not null)
{
_output.WriteLine(reply);
}
}
}

private Thread StartReader()
{
Thread thread = new(ReadLines) { IsBackground = true, Name = "Pixely input automation reader" };
thread.Start();
return thread;
}

private void ReadLines()
{
while (_input.ReadLine() is { } line)
{
_pendingLines.Enqueue(line);
}
}
}
1 change: 1 addition & 0 deletions src/Pixely/Input/InputAutomationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static ServiceCollection AddInputAutomation(this ServiceCollection servic
{
services.AddSingleton<InputAutomation, PixelyFactory>();
services.AddAlias<IInputAutomation, InputAutomation>();
services.AddSingleton<InputAutomationConsole, PixelyFactory>();
return services;
}
}
6 changes: 6 additions & 0 deletions src/Pixely/PixelyFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ internal InputAutomation CreateInputAutomation(WindowRegistry windowRegistry, Mo
return new InputAutomation(windowRegistry, mouseService, keyboardService, textInputService);
}

internal InputAutomationConsole CreateInputAutomationConsole(InputAutomation inputAutomation)
{
// Raw standard streams, so reading never changes the terminal mode the way Console.In does on Unix.
return new InputAutomationConsole(inputAutomation, new StreamReader(Console.OpenStandardInput()), Console.Out);
}

internal EventService CreateEventService(
KeyboardService keyboardService,
GamepadService gamepadService,
Expand Down
95 changes: 95 additions & 0 deletions tests/Pixely.Tests/InputAutomationCommandInterpreterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Numerics;
using Pixely.Input;

namespace Pixely.Tests;

public sealed class InputAutomationCommandInterpreterTests
{
[TestCase("mouse move 320 180", "MouseMoveTo <320, 180>")]
[TestCase("mouse moveby 10 -5.5", "MouseMoveBy <10, -5.5>")]
[TestCase("mouse down left 1 2", "MouseDown Left <1, 2>")]
[TestCase("mouse up Right 1 2", "MouseUp Right <1, 2>")]
[TestCase(" mouse click Left 330 175", "MouseClick Left <330, 175>")]
[TestCase("mouse wheel 0 -1 330 175", "MouseWheel <0, -1> <330, 175>")]
[TestCase("key down LeftCtrl", "KeyDown LeftCtrl")]
[TestCase("key up e", "KeyUp E")]
[TestCase("key press Return", "KeyPress Return")]
[TestCase("text hello world ", "TextInput 'hello world '")]
[TestCase("text", "TextInput ''")]
public void Execute_DispatchesCommand(string line, string expectedCall)
{
RecordingAutomation automation = new();
InputAutomationCommandInterpreter interpreter = new(automation);

string? reply = interpreter.Execute(line);

Assert.Multiple(() =>
{
Assert.That(reply, Is.EqualTo("ok"));
Assert.That(automation.Calls, Is.EqualTo(new[] { expectedCall }));
});
}

[TestCase("", null)]
[TestCase(" ", null)]
[TestCase("# a comment", null)]
[TestCase("jump", "error: unknown command 'jump'")]
[TestCase("mouse move 320", "error: unknown command 'mouse move 320'")]
[TestCase("mouse move x 180", "error: invalid number 'x'")]
[TestCase("mouse click Center 1 2", "error: unknown MouseButton 'Center'")]
[TestCase("mouse click 9 1 2", "error: unknown MouseButton '9'")]
[TestCase("key press Ctrl", "error: unknown Scancode 'Ctrl'")]
public void Execute_RejectsMalformedLineWithoutDispatching(string line, string? expectedReply)
{
RecordingAutomation automation = new();
InputAutomationCommandInterpreter interpreter = new(automation);

string? reply = interpreter.Execute(line);

Assert.Multiple(() =>
{
Assert.That(reply, Is.EqualTo(expectedReply));
Assert.That(automation.Calls, Is.Empty);
});
}

[Test]
public void Execute_PropagatesAutomationFailure()
{
InputAutomationCommandInterpreter interpreter = new(new ThrowingAutomation());

Assert.Throws<InvalidOperationException>(() => interpreter.Execute("key press A"));
}

private sealed class RecordingAutomation : IInputAutomation
{
public List<string> Calls { get; } = new();

public void MouseMoveTo(Vector2 windowPosition, ViewScope viewScope = default) => Calls.Add($"MouseMoveTo {windowPosition}");
public void MouseMoveBy(Vector2 delta, ViewScope viewScope = default) => Calls.Add($"MouseMoveBy {delta}");
public void MouseDown(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Calls.Add($"MouseDown {button} {windowPosition}");
public void MouseUp(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Calls.Add($"MouseUp {button} {windowPosition}");
public void MouseClick(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Calls.Add($"MouseClick {button} {windowPosition}");
public void MouseWheel(Vector2 delta, Vector2 windowPosition, ViewScope viewScope = default) => Calls.Add($"MouseWheel {delta} {windowPosition}");
public void KeyDown(Scancode scancode, ViewScope viewScope = default) => Calls.Add($"KeyDown {scancode}");
public void KeyUp(Scancode scancode, ViewScope viewScope = default) => Calls.Add($"KeyUp {scancode}");
public void KeyPress(Scancode scancode, ViewScope viewScope = default) => Calls.Add($"KeyPress {scancode}");
public void TextInput(string text, ViewScope viewScope = default) => Calls.Add($"TextInput '{text}'");
}

private sealed class ThrowingAutomation : IInputAutomation
{
private static InvalidOperationException Failure => new("ViewScope 0 is not registered");

public void MouseMoveTo(Vector2 windowPosition, ViewScope viewScope = default) => throw Failure;
public void MouseMoveBy(Vector2 delta, ViewScope viewScope = default) => throw Failure;
public void MouseDown(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => throw Failure;
public void MouseUp(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => throw Failure;
public void MouseClick(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => throw Failure;
public void MouseWheel(Vector2 delta, Vector2 windowPosition, ViewScope viewScope = default) => throw Failure;
public void KeyDown(Scancode scancode, ViewScope viewScope = default) => throw Failure;
public void KeyUp(Scancode scancode, ViewScope viewScope = default) => throw Failure;
public void KeyPress(Scancode scancode, ViewScope viewScope = default) => throw Failure;
public void TextInput(string text, ViewScope viewScope = default) => throw Failure;
}
}
57 changes: 57 additions & 0 deletions tests/Pixely.Tests/InputAutomationConsoleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Numerics;
using Pixely.Input;

namespace Pixely.Tests;

public sealed class InputAutomationConsoleTests
{
[Test]
public void Update_RunsQueuedLinesOnCallingThreadAndRepliesInOrder()
{
List<(string Call, int ThreadId)> calls = new();
StringWriter output = new();
InputAutomationConsole console = new(new RecordingAutomation(calls), new StringReader("key press A\n\nbogus\nkey press B\n"), output);

DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (calls.Count < 2 && DateTime.UtcNow < deadline)
{
console.Update();
Thread.Yield();
}

Assert.Multiple(() =>
{
Assert.That(calls.Select(call => call.Call), Is.EqualTo(new[] { "KeyPress A", "KeyPress B" }));
Assert.That(calls.Select(call => call.ThreadId), Is.All.EqualTo(Environment.CurrentManagedThreadId));
Assert.That(output.ToString(), Is.EqualTo($"ok{Environment.NewLine}error: unknown command 'bogus'{Environment.NewLine}ok{Environment.NewLine}"));
});
}

[Test]
public void Update_WithEmptyInput_DoesNothing()
{
StringWriter output = new();
InputAutomationConsole console = new(new RecordingAutomation(new()), new StringReader(""), output);

console.Update();
console.Update();

Assert.That(output.ToString(), Is.Empty);
}

private sealed class RecordingAutomation(List<(string Call, int ThreadId)> calls) : IInputAutomation
{
private void Record(string call) => calls.Add((call, Environment.CurrentManagedThreadId));

public void MouseMoveTo(Vector2 windowPosition, ViewScope viewScope = default) => Record($"MouseMoveTo {windowPosition}");
public void MouseMoveBy(Vector2 delta, ViewScope viewScope = default) => Record($"MouseMoveBy {delta}");
public void MouseDown(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Record($"MouseDown {button} {windowPosition}");
public void MouseUp(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Record($"MouseUp {button} {windowPosition}");
public void MouseClick(MouseButton button, Vector2 windowPosition, ViewScope viewScope = default) => Record($"MouseClick {button} {windowPosition}");
public void MouseWheel(Vector2 delta, Vector2 windowPosition, ViewScope viewScope = default) => Record($"MouseWheel {delta} {windowPosition}");
public void KeyDown(Scancode scancode, ViewScope viewScope = default) => Record($"KeyDown {scancode}");
public void KeyUp(Scancode scancode, ViewScope viewScope = default) => Record($"KeyUp {scancode}");
public void KeyPress(Scancode scancode, ViewScope viewScope = default) => Record($"KeyPress {scancode}");
public void TextInput(string text, ViewScope viewScope = default) => Record($"TextInput '{text}'");
}
}