Skip to content

Commit fffb8eb

Browse files
fix: Keyboard lost key-up bug
1 parent 1c190d5 commit fffb8eb

7 files changed

Lines changed: 321 additions & 1 deletion

File tree

‎src/Spice86.Core/Emulator/Devices/Input/Keyboard/PS2Keyboard.cs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ namespace Spice86.Core.Emulator.Devices.Input.Keyboard;
88

99
using System.Collections.Generic;
1010
using System.Diagnostics;
11+
using System.Linq;
1112

1213
/// <summary>
1314
/// Emulates a PS/2 keyboard device, handling key events, scancode translation, and communication with a keyboard
@@ -33,6 +34,14 @@ public partial class PS2Keyboard {
3334
private RepeatData _repeat = new() { Key = PcKeyboardKey.None, WaitMs = 0, PauseMs = 500, RateMs = 33 };
3435
private readonly Dictionary<byte, Set3CodeInfoEntry> _set3CodeInfo = new();
3536

37+
/// <summary>
38+
/// Keys the device currently believes are held down. A host key-up can be lost (window lost focus,
39+
/// input dropped while paused, frame-buffer overflow) and the guest only ever clears a modifier on its
40+
/// BREAK scancode, so this set is the bookkeeping that lets the device reconcile and release what it
41+
/// still thinks is down.
42+
/// </summary>
43+
private readonly HashSet<PcKeyboardKey> _pressedKeys = new();
44+
3645
/// <summary>
3746
/// State of keyboard LEDs, as requested via keyboard controller
3847
/// </summary>
@@ -130,6 +139,11 @@ private void EnqueueScanCodeFrame(List<byte>? scanCode) {
130139
if (_keyboardFrameBuffer.Count == BufferSizeInScancodes) {
131140
_keyboardFrameBuffer.Clear();
132141
_bufferOverflowed = true;
142+
// A discarded key-up would otherwise latch that key (and its modifier bits) in the guest forever.
143+
// Reconcile the held-key set so no key is believed to be down any more.
144+
_pressedKeys.Clear();
145+
_repeat.Key = PcKeyboardKey.None;
146+
_repeat.WaitMs = 0;
133147
return;
134148
}
135149

@@ -521,6 +535,44 @@ internal void NotifyReadyForFrame() {
521535
TransferNextBufferedFrameIfReady();
522536
}
523537

538+
/// <summary>
539+
/// Releases every key the device still believes is down, emitting a proper BREAK frame for each one,
540+
/// and stops any in-flight typematic repeat.
541+
/// </summary>
542+
/// <remarks>
543+
/// Call this when the host's key state can no longer be trusted: the window lost activation, the
544+
/// emulator was paused, or the keyboard frame buffer overflowed and discarded a key-up. The guest only
545+
/// clears a modifier on its BREAK scancode, so a lost key-up otherwise latches it forever (e.g. Alt
546+
/// left in <c>KB_FLAG</c>), silently changing how every other key is translated. This method is safe to
547+
/// call on the emulator thread only; marshal cross-thread calls onto the emulator thread first.
548+
/// </remarks>
549+
public void ReleaseAllKeys() {
550+
if (_pressedKeys.Count == 0) {
551+
_repeat.Key = PcKeyboardKey.None;
552+
_repeat.WaitMs = 0;
553+
return;
554+
}
555+
556+
// Emit a BREAK frame for each held key in a deterministic order so the guest sees a stable sequence.
557+
List<PcKeyboardKey> heldKeys = _pressedKeys.OrderBy(key => key).ToList();
558+
foreach (PcKeyboardKey key in heldKeys) {
559+
EnqueueScanCodeFrame(GetReleaseScanCode(key));
560+
}
561+
562+
_pressedKeys.Clear();
563+
_repeat.Key = PcKeyboardKey.None;
564+
_repeat.WaitMs = 0;
565+
}
566+
567+
private List<byte> GetReleaseScanCode(PcKeyboardKey keyType) {
568+
return _codeSet switch {
569+
1 => _scancodeConverter.GetScanCode1(keyType, isPressed: false),
570+
2 => _scancodeConverter.GetScanCode2(keyType, isPressed: false),
571+
3 => _scancodeConverter.GetScanCode3(keyType, isPressed: false),
572+
_ => _scancodeConverter.GetScanCode1(keyType, isPressed: false),
573+
};
574+
}
575+
524576
/// <summary>
525577
/// Adds a key event to be processed by the keyboard.
526578
/// </summary>
@@ -531,6 +583,16 @@ public void EnqueueKeyEvent(PcKeyboardKey keyType, bool isPressed) {
531583
return;
532584
}
533585

586+
if (keyType is PcKeyboardKey.None or PcKeyboardKey.Pause or PcKeyboardKey.PrintScreen) {
587+
// Keys that are not tracked as held: Pause has no BREAK frame, PrintScreen is a momentary
588+
// key-chord, and None is an unsupported key. Keeping them out of the held-key set means a
589+
// later release-all does not try to emit a BREAK scancode that does not exist.
590+
} else if (isPressed) {
591+
_pressedKeys.Add(keyType);
592+
} else {
593+
_pressedKeys.Remove(keyType);
594+
}
595+
534596
List<byte> scanCode = new();
535597

536598
switch (_codeSet) {

‎src/Spice86.Core/Emulator/Mcp/EmulatorMcpServices.cs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,17 @@ public sealed class EmulatorMcpServices(
7070
/// <summary>
7171
/// Gets or sets the Intel 8042 PS/2 controller used for keyboard/mouse MCP automation.
7272
/// </summary>
73-
public Intel8042Controller? Intel8042Controller { get; set; }
73+
public Intel8042Controller? Intel8042Controller {
74+
get => _intel8042Controller;
75+
set {
76+
_intel8042Controller = value;
77+
if (value is not null) {
78+
SubscribeKeyboardReconcileOnPause();
79+
}
80+
}
81+
}
82+
83+
private Intel8042Controller? _intel8042Controller;
7484

7585
/// <summary>
7686
/// Gets or sets the input event hub. MCP tools must post keyboard/mouse
@@ -124,6 +134,26 @@ public sealed class EmulatorMcpServices(
124134
/// </summary>
125135
public Dos? Dos { get; set; }
126136

137+
private bool _keyboardReconcileSubscribed;
138+
139+
/// <summary>
140+
/// Subscribes the keyboard device to release all held keys whenever the emulator pauses. Key-ups
141+
/// dropped while paused (or lost when the window deactivates) would otherwise leave a modifier
142+
/// latched in the guest's BIOS state. The release is marshalled onto the emulator thread via
143+
/// <see cref="InputEventHub"/> so it is safe to call from the UI thread.
144+
/// </summary>
145+
internal void SubscribeKeyboardReconcileOnPause() {
146+
if (_keyboardReconcileSubscribed
147+
|| Intel8042Controller is null
148+
|| InputEventHub is null) {
149+
return;
150+
}
151+
152+
InputEventHub inputEventHub = InputEventHub;
153+
PauseHandler.Pausing += () => inputEventHub.ReleaseAllKeys();
154+
_keyboardReconcileSubscribed = true;
155+
}
156+
127157
// Shared MCP breakpoint tracking state (survives transient tool instances)
128158
private readonly object _mcpBreakpointsLock = new();
129159
private readonly Lock _toolsLock = new();

‎src/Spice86.Core/Emulator/VM/InputEventQueue.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ private void Enqueue(Action action) {
5959
/// </summary>
6060
public void PostToEmulatorThread(Action action) => Enqueue(action);
6161

62+
/// <summary>
63+
/// Thread-safe request to release every key the keyboard device still believes is down, emitting a
64+
/// proper BREAK frame for each one. Call this when the host's key state can no longer be trusted
65+
/// (e.g. the window lost activation and a key-up was never delivered), so the guest does not keep a
66+
/// modifier latched. The release is enqueued and runs on the emulator thread with the rest of the input.
67+
/// </summary>
68+
public void ReleaseAllKeys() =>
69+
Enqueue(() => KeyboardDevice?.ReleaseAllKeys());
70+
71+
/// <summary>
72+
/// The PS/2 keyboard device whose held-key state is reconciled by <see cref="ReleaseAllKeys"/>.
73+
/// </summary>
74+
internal PS2Keyboard? KeyboardDevice { get; set; }
75+
6276
/// <summary>
6377
/// Enqueues a keyboard event to be fired on the emulator thread, following
6478
/// the same path as UI keyboard events (PhysicalKey → PS2Keyboard scancode pipeline).

‎src/Spice86/Spice86DependencyInjection.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai
562562
configuration.FailOnUnhandledPort, loggerService, inputEventHub);
563563
emulatorMcpServices.Intel8042Controller = intel8042Controller;
564564
emulatorMcpServices.BiosKeyboardBuffer = biosKeyboardBuffer;
565+
inputEventHub.KeyboardDevice = intel8042Controller.KeyboardDevice;
565566
BiosKeyboardInt9Handler biosKeyboardInt9Handler = new(memory, biosDataArea,
566567
stack, state, cfgCpu, dualPic, systemBiosInt15Handler,
567568
intel8042Controller, biosKeyboardBuffer, loggerService);
@@ -795,6 +796,7 @@ internal Spice86DependencyInjection(Configuration configuration, MainWindow? mai
795796

796797
if (mainWindow != null && uiDispatcher != null &&
797798
hostStorageProvider != null && textClipboard != null && mainWindowViewModel != null) {
799+
mainWindowViewModel.OnWindowDeactivated = () => inputEventHub.ReleaseAllKeys();
798800
IMessenger messenger = WeakReferenceMessenger.Default;
799801

800802
BreakpointsTabPlugin breakpointsTabPlugin = new(state, pauseHandler, messenger,

‎src/Spice86/ViewModels/MainWindowViewModel.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ public double? TimeMultiplier {
106106
/// <summary>Fired when this view model is being disposed so the composition root can tear down.</summary>
107107
internal event Action? Disposing;
108108

109+
/// <summary>
110+
/// Thread-safe action that releases every key the keyboard device still believes is down. Invoked when
111+
/// the window loses activation, so a key-up swallowed by the shell (e.g. Alt+Tab) does not latch a
112+
/// modifier in the guest. Assigned by the composition root; null in headless mode.
113+
/// </summary>
114+
public Action? OnWindowDeactivated { get; set; }
115+
109116
public MainWindowViewModel(
110117
EmulatorDisplayViewModel display,
111118
EmulatorSession session,

‎src/Spice86/Views/MainWindow.axaml.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
162162

163163
private void OnWindowDeactivated(object? sender, EventArgs e) {
164164
ReleaseCapture();
165+
// The shell may swallow key-ups (e.g. Alt+Tab) while the window is inactive. Release any key the
166+
// device still believes is down so the guest does not keep a modifier latched.
167+
(DataContext as MainWindowViewModel)?.OnWindowDeactivated?.Invoke();
165168
}
166169

167170
private void OnWindowPositionChanged(object? sender, PixelPointEventArgs e) {

0 commit comments

Comments
 (0)