Repository navigation
feat: first stab at adding cross-platform support for the console/input handler - #12
robinbaxon wants to merge 3 commits into
Conversation
|
Will refactor and adhere to #7, leaving consoleengine.windows alone |
…-platform project
…put (Claude Opus 5.5) - Pick Windows/cross-platform handlers at runtime (OperatingSystem.IsWindows) instead of compile time $(OS) defines; revert the Kernel32 stubs - Rewrite CrossPlatformConsoleHandler: alternate screen, no autowrap, diffed frames written in a single write, synchronized updates, truecolor/16 color output, clipping to the terminal size, terminal restored on exit/Ctrl+C - Read real key up/down state on macOS through CoreGraphics, calibrated against terminal input so it falls back to the terminal when macOS lacks Input Monitoring permission - Terminal input fallback that treats keys as held until auto repeat stops - Launch the logger in a new Terminal window on macOS, don't crash if it can't start - Remove the missing Pong project from the solution - Build and test on windows, macos and ubuntu in CI Code made by Claude (Opus 5.5)
002696a to
06b2b49
Compare
stho01
left a comment
There was a problem hiding this comment.
Code review of the cross-platform console/input support. 8 findings below: 6 correctness issues (input timing heuristics, logger startup, terminal restore, keyboard layouts) and 2 cleanups.
🤖 Generated with Claude Code
| continue; | ||
|
|
||
| // Auto repeat events arrive in a rapid stream, anything slower is a new key press. | ||
| var isRepeat = now - _terminalLastEvent[vk] < RepeatHoldTicks; |
There was a problem hiding this comment.
Bug: the first auto-repeat and slow frames count as new presses, and fast double-taps are swallowed.
Whether an event is a repeat is decided by comparing the frame timestamp now with the previous event for the same key, using a 150ms window.
- The OS waits before it starts repeating: about 375ms by default on macOS, 500–660ms on Linux desktops. So the first repeat is always more than 150ms after the press and is classified as a new press. Because
wasHeldis still true,repressedfiresPresseda second time. Example: holdingAin TerraForM'sInputNameScenetypesAA. - Every event drained in a frame shares the same
now. If a frame takes longer than 150ms, the first queued repeat in each frame looks like a new press, soPressedfires every frame while a key is held. - Pressing the same key twice within 150ms (typing
ll, for example) is treated as a repeat, so the second press is lost.
Suggestion: treat an event as a repeat when the key is still inside its hold window (now < _terminalHeldUntil[vk]), not by measuring the gap since the last event.
| { | ||
| // A terminal key is held until no new press/repeat arrives within this window. The first window has to | ||
| // outlast the OS' "delay until repeat", the following ones only the repeat interval. | ||
| private static readonly long InitialHoldTicks = Stopwatch.Frequency * 550 / 1000; |
There was a problem hiding this comment.
Bug: the 550ms initial hold window is shorter than common repeat delays.
On X11 the default repeat delay is 660ms, and macOS's longer "Delay Until Repeat" settings reach well over a second. With those settings a held key reports Released (and Held = false) after 550ms, then Pressed again when the first repeat arrives. For example, holding RIGHT in Platformer makes the player stop briefly, and any action triggered on Pressed (jump, menu select) fires twice.
Consider a more generous initial window, or make it configurable, for example with a CONSOLE_ENGINE_* environment variable like the existing ones.
| else | ||
| { | ||
| _loggerProcess.StartInfo.FileName = logger; | ||
| _loggerProcess.StartInfo.UseShellExecute = true; |
There was a problem hiding this comment.
Bug: on Linux the logger runs inside the game's own terminal.
When UseShellExecute = true and FileName is an executable file, .NET on Unix runs it directly instead of opening a new window. So on Linux Process.Start succeeds and the catch below is never reached. ConsoleEngine.Logger then:
- writes
Logger up and running...and every log line into the game's alternate screen, corrupting the rendered frame, - writes title escape sequences (
Console.Title) to the same terminal, - ends with
Console.ReadKey(), competing with the game for keyboard input.
On Linux it would be safer to start it in a terminal emulator (x-terminal-emulator -e …, or $TERMINAL), or not to start it at all and print a hint to run the logger manually.
| _originalHeight = SafeWindowSize(() => Console.WindowHeight, 0); | ||
| _initialized = true; | ||
|
|
||
| AppDomain.CurrentDomain.ProcessExit += (_, _) => RestoreTerminal(); |
There was a problem hiding this comment.
Bug: the terminal isn't restored after a crash.
The terminal is restored only from ProcessExit and CancelKeyPress, and .NET does not raise ProcessExit when the process dies from an unhandled exception. After an exception in a scene's Update, for example, the shell comes back inside the alternate screen, with auto-wrap off (?7l) and the cursor hidden, until the user runs reset.
Suggestion: also hook AppDomain.CurrentDomain.UnhandledException, or wrap the game loop in GameBase.Start in a try/finally that calls the handler's Close().
| void Map(int vk, params ushort[] macKeys) => map[vk] = macKeys; | ||
|
|
||
| // Letters (ANSI positions) | ||
| Map((int)Key.A, 0x00); Map((int)Key.S, 0x01); Map((int)Key.D, 0x02); Map((int)Key.F, 0x03); |
There was a problem hiding this comment.
Bug: the native key map follows physical key positions, so it's wrong on non-US layouts.
kVK_ANSI_* codes identify where a key sits on the keyboard, but Windows virtual key codes (and the terminal path through ConsoleKey) follow the active layout. Once calibration trusts native input (one arrow or Space press is enough), the results differ from Windows:
- On AZERTY, pressing the key labelled
AreportsKey.QandZreportsW, so WASD controls break. Dvorak is scrambled entirely. - On Nordic and German layouts, the OEM keys (
0xBA–0xDE) map to different virtual key codes than on Windows. - Letters that don't match also count as calibration misses, which can turn native input off altogether.
Either translate through the current layout (UCKeyTranslate / TISCopyCurrentKeyboardLayoutInputSource), or at least mention the US-layout assumption in the README next to CONSOLE_ENGINE_INPUT=terminal.
| { | ||
| _loggerProcess.Start(); | ||
| } | ||
| catch (Exception) |
There was a problem hiding this comment.
Issue: if the logger fails to start, the failure is ignored and messages pile up.
When Process.Start throws (on headless Linux, for example), the exception is swallowed but Log keeps running as if the logger were attached:
- the dispatch thread blocks in
Socket.Accept()forever, EnqueueMessagekeeps adding to the unboundedMessagesqueue (10 FPS reports per second whenShowFpsis on, plus everyLog.WriteLine), and nothing ever reads them.
Before this change the failure at least surfaced. Consider setting a flag in the catch that stops queuing messages, or bounding the queue.
| protected GameBase(int width, int height, FontInfo fontInfo) | ||
| { | ||
| _console = new RenderConsole(new ConsoleHandler(width, height, fontInfo)); | ||
| _console = new RenderConsole(OperatingSystem.IsWindows() |
There was a problem hiding this comment.
Nit: the platform choice is made in two places.
OperatingSystem.IsWindows() picks the Win32 or cross-platform implementation both here and in Input.cs, where it creates the static Handler. A small factory (for example Platform.CreateConsoleHandler(...) and Platform.CreateInputHandler()) would keep the decision in one place. That would make it easier to add a backend, or to force the ANSI renderer on Windows Terminal, without the two places getting out of sync.
|
|
||
| /// <summary>Writes the current down state of every mapped key into <paramref name="down"/>, indexed by Windows virtual key code.</summary> | ||
| /// <returns>true if any key is down.</returns> | ||
| public static bool Poll(bool[] down) |
There was a problem hiding this comment.
Nit: unused code.
The any value that Poll returns is ignored by its only caller (CrossPlatformInputHandler.Update), so Poll could return void. Similarly, CrossPlatformConsoleHandler.InitializeConsole calls SetFont(_fontInfo), which is empty, so the call and the _fontInfo field could both go.
Notable missing features:
ConsoleHandler.Resizable ❌
ConsoleHandler.SetFont ❌
Working TerraForM example on MacOS, using Kitty: ✅

Glitchy Asteroids example on MacOS, using Kitty: 🚧
