Wire real PS/2 mouse packets through to input events

Replace the mouse driver's synthetic stream with the real path, the
way the keyboard was wired:

- The auxiliary port's IRQ12 is enumerated on the mouse's own ACPI
  node (PNP0F13), and the kernel only lets a device's claimer bind or
  ack its IRQs — so ps2-bus now claims that node alongside the
  controller whenever port 2 carries a device, binds IRQ12 to its one
  endpoint, and re-arms whichever line the notification's badge names.
  The forwarding loop already routed auxiliary bytes by status bit 5.
- mouse-packet.zig (new, pure, host-tested): three-byte stream-mode
  packet assembly — bit-3 sync with resynchronization, ACK/BAT bytes
  dropped at packet start, nine-bit two's-complement movement,
  overflow packets discarded, and PS/2 positive-Y-up converted to the
  screen convention (positive down).
- mouse.zig mirrors the keyboard driver: no hardware claim, attaches
  to the bus as its mouse, and publishes button_down/button_up per
  changed button plus motion events with the pressed-button mask.

Verified end to end in QEMU via monitor mouse_move/mouse_button:
motion round-trips in screen coordinates, buttons transition with the
right mask, and keyboard events keep flowing alongside. The follow-up
is the IntelliMouse magic-knock for a scroll wheel (four-byte packets)
and scroll events.
This commit is contained in:
Daniel Samson
2026-07-11 23:09:04 +01:00
parent 5bba5d3363
commit 8c95525793
5 changed files with 290 additions and 27 deletions
+1
View File
@@ -511,6 +511,7 @@ pub fn build(b: *std.Build) void {
"system/devices/acpi-ids.zig", // _HID name decoding "system/devices/acpi-ids.zig", // _HID name decoding
"library/mmio/mmio.zig", // barriers assemble + registers round-trip "library/mmio/mmio.zig", // barriers assemble + registers round-trip
"system/drivers/ps2-bus/scancode.zig", // set-2 decode + keyboard state machine "system/drivers/ps2-bus/scancode.zig", // set-2 decode + keyboard state machine
"system/drivers/ps2-bus/mouse-packet.zig", // 3-byte mouse packet assembly
}) |root| { }) |root| {
const mod_tests = b.addTest(.{ const mod_tests = b.addTest(.{
.root_module = b.createModule(.{ .root_module = b.createModule(.{
+12 -5
View File
@@ -126,11 +126,18 @@ the service delivers to its endpoint, which only the same thread could receive).
control characters for Enter/Tab/Backspace/Escape, whose keysyms map to no Unicode. The control characters for Enter/Tab/Backspace/Escape, whose keysyms map to no Unicode. The
layout defaults to `us`; the bus can pass another as the driver's argv[2] — the seam for layout defaults to `us`; the bus can pass another as the driver's argv[2] — the seam for
a future settings source. a future settings source.
- **The mouse is still synthetic.** [mouse.zig](../system/drivers/ps2-bus/mouse.zig) - **The mouse is real too.** IRQ12 is enumerated on the auxiliary device's own ACPI node
publishes a placeholder motion/click stream; the hardware-free `input-source` likewise (PNP0F13), so the bus claims that node alongside the controller and routes both IRQs to
rotates through all three classes (including a synthetic joystick, which has no driver its one endpoint, acking whichever line the notification's badge names.
yet) via the `input.synthetic*Event` helpers. **Follow-up:** the mouse driver attaches to [mouse.zig](../system/drivers/ps2-bus/mouse.zig) attaches the way the keyboard does and
the bus the way the keyboard does and decodes 3/4-byte packets into mouse events. assembles the forwarded bytes with
[mouse-packet.zig](../system/drivers/ps2-bus/mouse-packet.zig) (three-byte stream-mode
packets: sync/overflow handling, nine-bit movement, screen-convention `dy` — host-tested
under `zig build test`) into `button_down`/`button_up` transitions and `motion` events.
**Follow-up:** the IntelliMouse magic-knock for a scroll wheel (four-byte packets) and
`scroll` events. The hardware-free `input-source` still rotates through all three classes
synthetically (including a joystick, which has no driver yet) via the
`input.synthetic*Event` helpers.
- **Drop-oldest under overflow** is a defined loss; the 16-slot ring absorbs normal bursts. - **Drop-oldest under overflow** is a defined loss; the 16-slot ring absorbs normal bursts.
Real backpressure/flow-control is future work. Real backpressure/flow-control is future work.
- **`publish` is unauthenticated** — any process may publish, consistent with the current - **`publish` is unauthenticated** — any process may publish, consistent with the current
+143
View File
@@ -0,0 +1,143 @@
//! PS/2 mouse packet assembly — the byte stream a streaming mouse sends, turned
//! into decoded movement/button reports.
//!
//! A standard PS/2 mouse in stream mode sends three-byte packets:
//!
//! byte 0: | Y ovf | X ovf | Y sign | X sign | 1 | middle | right | left |
//! byte 1: X movement (low eight bits; the sign bit lives in byte 0)
//! byte 2: Y movement (likewise)
//!
//! Movement is nine-bit two's complement, PS/2 convention: positive X right,
//! positive Y **up**. The decoded packet converts Y to the screen convention
//! (positive down), matching what every consumer of relative motion expects.
//! Bit 3 of byte 0 is always set — the resynchronization anchor: a byte at
//! packet start with bit 3 clear cannot be a packet header and is dropped.
//!
//! Everything here is pure (no imports beyond `std`, no IO), so it is
//! host-testable: the tests at the bottom run under `zig build test`.
const std = @import("std");
/// One decoded movement/button report, in screen convention (positive dy down).
pub const Packet = struct {
left: bool,
right: bool,
middle: bool,
dx: i16,
dy: i16,
};
const header_always_set: u8 = 1 << 3;
const header_left: u8 = 1 << 0;
const header_right: u8 = 1 << 1;
const header_middle: u8 = 1 << 2;
const header_x_sign: u8 = 1 << 4;
const header_y_sign: u8 = 1 << 5;
const header_x_overflow: u8 = 1 << 6;
const header_y_overflow: u8 = 1 << 7;
/// Device protocol bytes that can reach the packet stream around bring-up (the
/// acknowledge to enable-reporting, a reset's self-test result). Both have bit 3
/// set, so the header check alone cannot reject them; they are recognized only
/// at packet start, where a real header cannot be one of them in practice.
const response_acknowledge: u8 = 0xFA;
const response_self_test_passed: u8 = 0xAA;
/// Accumulates the byte stream into `Packet`s. Feed it every byte the mouse
/// sends; the third byte of each well-formed packet returns one.
pub const Assembler = struct {
bytes: [3]u8 = undefined,
count: u8 = 0,
pub fn feed(self: *Assembler, byte: u8) ?Packet {
if (self.count == 0) {
// Resynchronize: a packet must start with a plausible header.
if (byte & header_always_set == 0) return null;
if (byte == response_acknowledge or byte == response_self_test_passed) return null;
}
self.bytes[self.count] = byte;
self.count += 1;
if (self.count < 3) return null;
self.count = 0;
const header = self.bytes[0];
// An overflowed count is garbage by definition; discard the packet.
if (header & (header_x_overflow | header_y_overflow) != 0) return null;
return .{
.left = header & header_left != 0,
.right = header & header_right != 0,
.middle = header & header_middle != 0,
.dx = movement(self.bytes[1], header & header_x_sign != 0),
// PS/2 positive Y is up; screen positive Y is down.
.dy = -movement(self.bytes[2], header & header_y_sign != 0),
};
}
/// Nine-bit two's complement: the eight movement bits plus the header's sign.
fn movement(low: u8, negative: bool) i16 {
const value: i16 = low;
return if (negative) value - 256 else value;
}
};
// --- tests (host-run via `zig build test`) ------------------------------------
const testing = std.testing;
fn feedAll(assembler: *Assembler, bytes: []const u8) ?Packet {
var result: ?Packet = null;
for (bytes) |byte| {
if (assembler.feed(byte)) |packet| result = packet;
}
return result;
}
test "plain motion decodes with screen-convention y" {
var assembler = Assembler{};
const packet = feedAll(&assembler, &.{ 0x08, 5, 3 }).?;
try testing.expectEqual(@as(i16, 5), packet.dx);
try testing.expectEqual(@as(i16, -3), packet.dy); // PS/2 up 3 -> screen -3
try testing.expect(!packet.left and !packet.right and !packet.middle);
}
test "negative movement sign-extends through the header bits" {
var assembler = Assembler{};
// X sign and Y sign set: dx = 0xFB - 256 = -5, dy raw = 0xFE - 256 = -2 -> screen +2.
const packet = feedAll(&assembler, &.{ 0x08 | 0x10 | 0x20, 0xFB, 0xFE }).?;
try testing.expectEqual(@as(i16, -5), packet.dx);
try testing.expectEqual(@as(i16, 2), packet.dy);
}
test "buttons decode from the header" {
var assembler = Assembler{};
const packet = feedAll(&assembler, &.{ 0x08 | 0x01 | 0x02, 0, 0 }).?;
try testing.expect(packet.left);
try testing.expect(packet.right);
try testing.expect(!packet.middle);
}
test "a byte with bit 3 clear at packet start is dropped" {
var assembler = Assembler{};
// The stray 0x02 cannot be a header; the following packet still decodes.
try testing.expectEqual(@as(?Packet, null), assembler.feed(0x02));
const packet = feedAll(&assembler, &.{ 0x09, 1, 0 }).?;
try testing.expect(packet.left);
try testing.expectEqual(@as(i16, 1), packet.dx);
}
test "protocol bytes at packet start are dropped" {
var assembler = Assembler{};
try testing.expectEqual(@as(?Packet, null), assembler.feed(0xFA)); // enable-reporting ACK
try testing.expectEqual(@as(?Packet, null), assembler.feed(0xAA)); // self-test passed
const packet = feedAll(&assembler, &.{ 0x08, 7, 0 }).?;
try testing.expectEqual(@as(i16, 7), packet.dx);
}
test "an overflowed packet is discarded whole" {
var assembler = Assembler{};
try testing.expectEqual(@as(?Packet, null), feedAll(&assembler, &.{ 0x08 | 0x40, 0xFF, 0xFF }));
// The assembler is back at packet start.
const packet = feedAll(&assembler, &.{ 0x08, 1, 1 }).?;
try testing.expectEqual(@as(i16, 1), packet.dx);
}
+98 -18
View File
@@ -1,19 +1,52 @@
//! PS/2 Mouse Driver //! PS/2 Mouse Driver
//! //!
//! Spawned by the ps2-bus driver once the controller is initialized and port 2 //! Spawned by the ps2-bus driver once the controller is initialized and the port
//! has passed its interface test and device reset. The bus driver hands us our //! has passed its interface test and device reset. The bus driver hands us our
//! device HID as argv[1]; we use it to locate our own device descriptor. //! device HID as argv[1].
//!
//! Like the keyboard, this driver never touches the hardware: the 8042's ports
//! and both port IRQs are owned by the ps2-bus driver (the auxiliary port's
//! IRQ12 lives on the PNP0F13 node, which the bus claims alongside the
//! controller). The driver **attaches** to the bus and receives every byte the
//! mouse sends as a forwarded asynchronous message. The bytes assemble into
//! three-byte packets, and each packet becomes input-protocol events:
//!
//! packet -> button transitions -> button_down / button_up
//! -> movement -> motion (dx/dy, screen convention)
const std = @import("std"); const std = @import("std");
const runtime = @import("runtime"); const runtime = @import("runtime");
const ps2 = @import("ps2-library.zig"); const ps2 = @import("ps2-library.zig");
const mouse_packet = @import("mouse-packet.zig");
const device = runtime.device; const device = runtime.device;
const ipc = runtime.ipc;
const protocol = runtime.input_protocol;
fn writeLine(comptime fmt: []const u8, arguments: anytype) void { fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
var line: [128]u8 = undefined; var line: [128]u8 = undefined;
_ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return); _ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
} }
/// Look up the ps2-bus service, retrying while the bus (which spawned us before
/// registering) is still coming up.
fn lookupBus() ?ipc.Handle {
var attempts: usize = 0;
while (attempts < 100) : (attempts += 1) {
if (ipc.lookup(.ps2_bus)) |handle| return handle;
runtime.system.sleep(50);
}
return null;
}
/// The protocol's pressed-button bitmask for a packet.
fn buttonMask(packet: mouse_packet.Packet) u32 {
var mask: u32 = 0;
if (packet.left) mask |= protocol.mouse_button_left;
if (packet.right) mask |= protocol.mouse_button_right;
if (packet.middle) mask |= protocol.mouse_button_middle;
return mask;
}
pub fn main(init: runtime.process.Init) void { pub fn main(init: runtime.process.Init) void {
const hid = init.arguments.get(1).?; const hid = init.arguments.get(1).?;
@@ -27,34 +60,81 @@ pub fn main(init: runtime.process.Init) void {
_ = runtime.system.write("system/drivers/ps2-bus/mouse: out of memory\n"); _ = runtime.system.write("system/drivers/ps2-bus/mouse: out of memory\n");
return; return;
}; };
const mouse_device_descriptor = device.findDeviceDescriptorByHid(buffer, hid) orelse { if (device.findDeviceDescriptorByHid(buffer, hid) == null) {
writeLine("system/drivers/ps2-bus/mouse: no device for hid {s}\n", .{hid}); writeLine("system/drivers/ps2-bus/mouse: no device for hid {s}\n", .{hid});
return; return;
}; }
// The mouse's own node (PNP0F13) carries IRQ12 and is not claimed by the bus, // Attach to the bus: hand it our endpoint, and it forwards every byte the
// so this driver takes exclusive ownership of it. Port IO still goes through // mouse sends (it owns the controller; we own the decoding).
// the bus, which owns the shared 8042 ports. const bus = lookupBus() orelse {
if (!device.claim(mouse_device_descriptor.id)) { _ = runtime.system.write("system/drivers/ps2-bus/mouse: ps2-bus service unavailable\n");
writeLine("system/drivers/ps2-bus/mouse: unable to claim device for hid {s}\n", .{hid}); return;
};
const endpoint = ipc.createIpcEndpoint() orelse {
_ = runtime.system.write("system/drivers/ps2-bus/mouse: no endpoint\n");
return;
};
var attach = ps2.AttachRequest{ .device_type = @intFromEnum(ps2.DeviceType.mouse) };
var attach_reply: [@sizeOf(ps2.AttachReply)]u8 = undefined;
const attached = ipc.callCap(bus, std.mem.asBytes(&attach), &attach_reply, endpoint) catch {
_ = runtime.system.write("system/drivers/ps2-bus/mouse: attach call failed\n");
return;
};
if (attached.len < @sizeOf(ps2.AttachReply) or
std.mem.bytesToValue(ps2.AttachReply, attach_reply[0..@sizeOf(ps2.AttachReply)]).status != 0)
{
_ = runtime.system.write("system/drivers/ps2-bus/mouse: attach refused\n");
return; return;
} }
writeLine("system/drivers/ps2-bus/mouse: claimed device for hid {s}\n", .{hid});
// Broadcast mouse events through the input service so programs can listen for them // Broadcast mouse events through the input service so programs can listen
// (docs/input.md). As with the keyboard, decoding real PS/2 mouse packets is a // for them (docs/input.md).
// follow-up; for now we publish the synthetic stand-in stream. The fan-out path is
// real, only the source of the movement is placeholder.
var source = runtime.input.connectSource() orelse { var source = runtime.input.connectSource() orelse {
_ = runtime.system.write("system/drivers/ps2-bus/mouse: input service unavailable\n"); _ = runtime.system.write("system/drivers/ps2-bus/mouse: input service unavailable\n");
return; return;
}; };
_ = runtime.system.write("system/drivers/ps2-bus/mouse: ok\n"); _ = runtime.system.write("system/drivers/ps2-bus/mouse: ok\n");
var step: usize = 0; var assembler = mouse_packet.Assembler{};
while (true) : (step +%= 1) { var buttons: u32 = 0;
_ = source.publishMouseEvent(runtime.input.syntheticMouseEvent(step)); var receive: [@sizeOf(ps2.ForwardedByte)]u8 = undefined;
runtime.system.sleep(200); while (true) {
const got = ipc.replyWait(endpoint, &.{}, &receive, null);
if (!got.isMessage() or got.len < @sizeOf(ps2.ForwardedByte)) continue;
const forwarded = std.mem.bytesToValue(ps2.ForwardedByte, receive[0..@sizeOf(ps2.ForwardedByte)]);
const packet = assembler.feed(@intCast(forwarded.byte & 0xFF)) orelse continue;
const new_buttons = buttonMask(packet);
// A button transition per changed button, carrying the new whole mask.
const changed = buttons ^ new_buttons;
for ([_]u32{ protocol.mouse_button_left, protocol.mouse_button_right, protocol.mouse_button_middle }) |button| {
if (changed & button == 0) continue;
const kind: protocol.MouseEventKind = if (new_buttons & button != 0) .button_down else .button_up;
_ = source.publishMouseEvent(.{
.kind = @intFromEnum(kind),
.button = button,
.dx = 0,
.dy = 0,
.scroll_x = 0,
.scroll_y = 0,
.buttons = new_buttons,
});
}
buttons = new_buttons;
if (packet.dx != 0 or packet.dy != 0) {
_ = source.publishMouseEvent(.{
.kind = @intFromEnum(protocol.MouseEventKind.motion),
.button = 0,
.dx = packet.dx,
.dy = packet.dy,
.scroll_x = 0,
.scroll_y = 0,
.buttons = new_buttons,
});
}
} }
} }
+36 -4
View File
@@ -229,19 +229,41 @@ pub fn main() void {
} }
// From here on, only the interrupt path reads the data port. Drop anything a // From here on, only the interrupt path reads the data port. Drop anything a
// device sent between enable-scanning and now, bind the IRQ, and only then // device sent between enable-scanning and now, bind the IRQs, and only then
// let the controller raise it — an interrupt with nobody bound is lost. // let the controller raise them — an interrupt with nobody bound is lost.
controller.drainOutputBuffer(); controller.drainOutputBuffer();
if (!device.irqBind(controller.device_id, interrupt_index, endpoint)) { if (!device.irqBind(controller.device_id, interrupt_index, endpoint)) {
_ = runtime.system.write("system/drivers/ps2-bus: irq_bind failed\n"); _ = runtime.system.write("system/drivers/ps2-bus: irq_bind failed\n");
return; return;
} }
// Port 2's interrupt (IRQ12) is enumerated on the auxiliary device's own ACPI
// node (PNP0F13), not on the controller's — so if port 2 carries a device,
// claim that node too and route its IRQ to the same endpoint. The IRQ belongs
// to the *port*, whatever device identify found on it.
var maybe_auxiliary_interrupt: ?struct { device_id: u64, interrupt_index: u64, gsi: u64 } = null;
if (port_device_types[@intFromEnum(ps2.Port.Two)] != null) {
if (device.findDeviceDescriptorByHid(buffer, "PNP0F13")) |descriptor| {
if (findInterruptResourceIndex(descriptor)) |auxiliary_index| {
if (device.claim(descriptor.id) and device.irqBind(descriptor.id, auxiliary_index, endpoint)) {
maybe_auxiliary_interrupt = .{
.device_id = descriptor.id,
.interrupt_index = auxiliary_index,
.gsi = descriptor.resources[auxiliary_index].start,
};
} else {
_ = runtime.system.write("system/drivers/ps2-bus: auxiliary irq_bind failed\n");
}
}
}
}
var configuration = controller.readConfigurationByte() orelse { var configuration = controller.readConfigurationByte() orelse {
_ = runtime.system.write("system/drivers/ps2-bus: controller configuration timed out\n"); _ = runtime.system.write("system/drivers/ps2-bus: controller configuration timed out\n");
return; return;
}; };
if (port_device_types[@intFromEnum(ps2.Port.One)] != null) configuration |= ps2.Port.One.interruptBit(); if (port_device_types[@intFromEnum(ps2.Port.One)] != null) configuration |= ps2.Port.One.interruptBit();
if (port_device_types[@intFromEnum(ps2.Port.Two)] != null) configuration |= ps2.Port.Two.interruptBit(); if (maybe_auxiliary_interrupt != null) configuration |= ps2.Port.Two.interruptBit();
_ = controller.writeConfigurationByte(configuration); _ = controller.writeConfigurationByte(configuration);
_ = runtime.system.write("system/drivers/ps2-bus: ok\n"); _ = runtime.system.write("system/drivers/ps2-bus: ok\n");
@@ -269,7 +291,17 @@ pub fn main() void {
// An unattached port's byte is dropped — e.g. a keystroke before // An unattached port's byte is dropped — e.g. a keystroke before
// the keyboard driver has attached. // the keyboard driver has attached.
} }
_ = device.irqAck(controller.device_id, interrupt_index); // Re-arm the line that woke us: the notification badge carries the
// GSI, and IRQ1 and IRQ12 are acked through different device claims.
if (maybe_auxiliary_interrupt) |auxiliary| {
if (got.source() == auxiliary.gsi) {
_ = device.irqAck(auxiliary.device_id, auxiliary.interrupt_index);
} else {
_ = device.irqAck(controller.device_id, interrupt_index);
}
} else {
_ = device.irqAck(controller.device_id, interrupt_index);
}
continue; continue;
} }
reply_len = handleAttach(receive[0..got.len], got, &reply_buffer); reply_len = handleAttach(receive[0..got.len], got, &reply_buffer);