Generalize input module to mouse and joystick/gamepad events

Extend the input service beyond the keyboard so mouse and joystick/gamepad
drivers can broadcast too, with per-device publish and subscribe methods.

- protocol: KeyEvent joins MouseEvent (motion/buttons/scroll) and
  JoystickEvent (axes/buttons), all carried in a common InputEvent envelope
  tagged with a DeviceKind. A subscribe request carries a device_mask, so a
  subscriber names the classes it wants and the service routes each event only
  to interested subscribers (a mouse-only listener never wakes for keystrokes).
- runtime: per-device publish methods (publishKeyboardEvent/publishMouseEvent/
  publishJoystickEvent) and subscribe helpers (subscribeKeyboard/Mouse/Joystick,
  each typed, plus subscribe(mask)/subscribeAll returning the tagged envelope).
- service: subscriber table gains a device_mask; broadcast routes by the
  event's device class.
- mouse driver now publishes (synthetic) mouse events like the keyboard driver;
  input-source cycles all three classes; input-test subscribes to all and only
  emits its "ok" marker once it has received one of each class — so the passing
  test proves per-device routing, not just delivery. Real HID decoding stays a
  follow-up.

No kernel changes: ipc_send is generic and the 36-byte InputEvent fits its
64-byte payload. Full QEMU suite 48/48; serial log confirms keyboard, mouse,
and joystick all reach one subscription.
This commit is contained in:
Daniel Samson
2026-07-11 15:21:09 +01:00
parent 65244e3103
commit 1bf91115dd
10 changed files with 457 additions and 160 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn main() void {
var step: usize = 0;
while (true) : (step +%= 1) {
_ = source.publish(runtime.input.syntheticEvent(step));
_ = source.publishKeyboardEvent(runtime.input.syntheticKeyEvent(step));
runtime.system.sleep(200);
}
}
+14 -1
View File
@@ -40,8 +40,21 @@ pub fn main() void {
}
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
// (docs/input.md). As with the keyboard, decoding real PS/2 mouse packets is a
// 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 {
_ = runtime.system.write("system/drivers/ps2-bus/mouse: input service unavailable\n");
return;
};
_ = runtime.system.write("system/drivers/ps2-bus/mouse: ok\n");
while (true) runtime.system.sleep(1000);
var step: usize = 0;
while (true) : (step +%= 1) {
_ = source.publishMouseEvent(runtime.input.syntheticMouseEvent(step));
runtime.system.sleep(200);
}
}
pub const panic = runtime.panic;
+8 -7
View File
@@ -1587,13 +1587,14 @@ fn vfsTest(boot_information: *const BootInformation) void {
result();
}
/// The full input path: spawn the input service, a synthetic keyboard source, and a
/// subscriber from the initial_ramdisk. The source publishes key events; the service
/// broadcasts them (with the asynchronous ipc_send); the subscriber receives them and —
/// only once it has — heartbeats "input-test: ok". Seeing that marker proves an event
/// travelled source -> service -> subscriber over IPC, exercising the async buffered-send
/// primitive and capability-passing subscription. The source and service stay silent
/// after startup so the subscriber's line is the one left in the shared evidence buffer.
/// The full input path: spawn the input service, a synthetic source, and a subscriber from
/// the initial_ramdisk. The source publishes keyboard, mouse, and joystick events in turn;
/// the service routes them (with the asynchronous ipc_send) to the subscriber, which took
/// all three classes and — only once it has received one — heartbeats "input-test: ok".
/// Seeing that marker proves an event travelled source -> service -> subscriber over IPC,
/// exercising the async buffered-send primitive, capability-passing subscription, and
/// per-device routing. The source and service stay silent after startup so the subscriber's
/// line is the one left in the shared evidence buffer.
fn inputTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: input\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
+16 -9
View File
@@ -1,13 +1,14 @@
//! system/services/input-source — a hardware-free synthetic keyboard source, used to
//! exercise the input service end to end without a real PS/2 controller (the `input` test
//! case, and any bring-up where there is no keyboard). It stands in for a driver: it
//! connects to the input service and `publish`es a rolling stream of key events, which the
//! service broadcasts to every subscriber.
//! system/services/input-source — a hardware-free synthetic input source, used to exercise
//! the input service end to end without a real PS/2 controller (the `input` test case, and
//! any bring-up where there is no hardware). It stands in for a driver: it connects to the
//! input service and publishes a rolling stream that cycles through all device classes —
//! keyboard, mouse, and joystick/gamepad — which the service routes to interested
//! subscribers.
//!
//! It stays silent after startup (no per-event logging) so it can share the boot serial
//! transcript with a subscriber whose output is the test's success marker. The real
//! keyboard driver publishes the same synthetic stream today; swapping in decoded
//! scancodes is a follow-up (see docs/input.md).
//! keyboard and mouse drivers publish their own synthetic streams today; swapping in
//! decoded hardware is a follow-up (see docs/input.md).
const runtime = @import("runtime");
const input = runtime.input;
@@ -18,11 +19,17 @@ pub fn main() void {
_ = system.write("input-source: input service unavailable\n");
return;
};
_ = system.write("input-source: publishing synthetic key events\n");
_ = system.write("input-source: publishing synthetic input events\n");
var step: usize = 0;
while (true) : (step +%= 1) {
_ = source.publish(input.syntheticEvent(step));
// Rotate across the device classes so every publish path (and the service's
// per-device routing) is exercised.
switch (step % 3) {
0 => _ = source.publishKeyboardEvent(input.syntheticKeyEvent(step)),
1 => _ = source.publishMouseEvent(input.syntheticMouseEvent(step)),
else => _ = source.publishJoystickEvent(input.syntheticJoystickEvent(step)),
}
system.sleep(200);
}
}
+29 -12
View File
@@ -1,9 +1,10 @@
//! system/services/input-test — the input service's client and test oracle, the input
//! counterpart of vfs-test. It `subscribe`s to the input service, then loops receiving the
//! events a source broadcasts. Once it has received at least one event it heartbeats
//! `"input-test: ok"` (repeatedly), which the in-kernel `input` test case watches for on
//! the serial log: seeing it proves an event travelled source -> service -> subscriber
//! over IPC and arrived intact.
//! counterpart of vfs-test. It subscribes to *all* device classes and loops receiving the
//! events a source broadcasts, logging each with its class. It emits the success marker
//! `"input-test: ok"` only **after it has received at least one of each class** (keyboard,
//! mouse, and joystick), then heartbeats it. So the in-kernel `input` test case seeing that
//! marker proves not just that IPC delivery works but that the service *routed* all three
//! device classes to one subscription — source -> service -> subscriber, per device.
const std = @import("std");
const runtime = @import("runtime");
@@ -16,20 +17,36 @@ fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
}
pub fn main() void {
var listener = input.subscribe() orelse {
var listener = input.subscribeAll() orelse {
_ = system.write("input-test: could not subscribe\n");
return;
};
_ = system.write("input-test: subscribed\n");
var received: usize = 0;
var seen_keyboard = false;
var seen_mouse = false;
var seen_joystick = false;
while (true) {
const event = listener.next() orelse continue;
received += 1;
// Report the round trip. The kernel test matches the "input-test: ok" prefix and
// requires it to recur, so the source staying up keeps this beating.
const kind: input.EventKind = @enumFromInt(event.kind);
writeLine("input-test: ok received {d} last kind={s} code={d} char={d}\n", .{ received, @tagName(kind), event.keycode, event.character });
// Decode the class-specific payload from the tagged envelope and note the class.
if (event.asKeyboard()) |key| {
seen_keyboard = true;
writeLine("input-test: got keyboard code={d} char={d}\n", .{ key.keycode, key.character });
} else if (event.asMouse()) |mouse| {
seen_mouse = true;
writeLine("input-test: got mouse dx={d} dy={d} buttons={d}\n", .{ mouse.dx, mouse.dy, mouse.buttons });
} else if (event.asJoystick()) |joystick| {
seen_joystick = true;
writeLine("input-test: got joystick control={d} value={d}\n", .{ joystick.control, joystick.value });
} else {
writeLine("input-test: got device={d}\n", .{event.device});
}
// The success marker: only once every class has been routed here does this appear,
// and then it heartbeats. Seeing "input-test: ok" proves per-device fan-out works.
if (seen_keyboard and seen_mouse and seen_joystick) {
_ = system.write("input-test: ok all classes received (keyboard, mouse, joystick)\n");
}
}
}
+19 -12
View File
@@ -1,8 +1,9 @@
//! system/services/input — the user-space input service. Shipped in the initial_ramdisk,
//! spawned as a ring-3 process, and published under the well-known `input` service id. It
//! is the fan-out point between **sources** (keyboard drivers) and **subscribers** (any
//! program that wants keyboard events): a source `publish`es a `KeyEvent`, and the service
//! pushes it to every subscriber.
//! is the fan-out point between **sources** (keyboard, mouse, and joystick/gamepad drivers)
//! and **subscribers** (any program that wants input): a source `publish`es an
//! `InputEvent`, and the service pushes it to every subscriber whose interest mask includes
//! that event's device class (keyboard / mouse / joystick).
//!
//! The delivery discipline is the whole design (see docs/input.md). The kernel's IPC is a
//! synchronous rendezvous: a server holds one pending reply, so it cannot park N
@@ -31,6 +32,9 @@ const Subscriber = struct {
used: bool = false,
endpoint: ipc.Handle = 0,
task_id: u32 = 0,
/// Which device classes this subscriber wants (an OR of protocol.device_*). An event
/// is delivered only if its device's bit is set here.
device_mask: u32 = 0,
};
var subscribers = [_]Subscriber{.{}} ** 8;
@@ -56,24 +60,25 @@ fn pruneDeadSubscribers() void {
}
}
/// Register `endpoint` (owned by task `task_id`) to receive events. Returns false if the
/// subscriber table is full.
fn addSubscriber(endpoint: ipc.Handle, task_id: u32) bool {
/// Register `endpoint` (owned by task `task_id`) to receive the device classes in
/// `device_mask`. Returns false if the subscriber table is full.
fn addSubscriber(endpoint: ipc.Handle, task_id: u32, device_mask: u32) bool {
for (&subscribers) |*sub| {
if (!sub.used) {
sub.* = .{ .used = true, .endpoint = endpoint, .task_id = task_id };
sub.* = .{ .used = true, .endpoint = endpoint, .task_id = task_id, .device_mask = device_mask };
return true;
}
}
return false;
}
/// Push `event` to every registered subscriber. `ipc.send` never blocks, so a slow or
/// dead subscriber cannot stall delivery to the others.
fn broadcast(event: protocol.KeyEvent) void {
/// Push `event` to every subscriber whose interest mask includes its device class.
/// `ipc.send` never blocks, so a slow or dead subscriber cannot stall delivery to others.
fn broadcast(event: protocol.InputEvent) void {
const bytes = std.mem.asBytes(&event);
const bit = protocol.deviceBit(event.device);
for (&subscribers) |*sub| {
if (sub.used) _ = ipc.send(sub.endpoint, bytes);
if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, bytes);
}
}
@@ -95,8 +100,10 @@ fn handle(message: []const u8, got: ipc.Received, out: []u8) usize {
switch (@as(protocol.Operation, @enumFromInt(request.operation))) {
.subscribe => {
const endpoint = got.cap orelse return reply.write(out, -1); // no endpoint passed
// A zero mask means "everything" (a subscriber that named no class still wants input).
const mask = if (request.device_mask == 0) protocol.device_all else request.device_mask;
pruneDeadSubscribers();
if (!addSubscriber(endpoint, @intCast(got.badge))) return reply.write(out, -1); // table full
if (!addSubscriber(endpoint, @intCast(got.badge), mask)) return reply.write(out, -1); // table full
return reply.write(out, 0);
},
.publish => {
+162 -39
View File
@@ -1,49 +1,80 @@
//! The input wire protocol — the message format spoken between the user-space input
//! service ([input.zig](input.zig)) and the two kinds of process that reach it: a
//! **source** (a keyboard driver) that `publish`es events, and a **subscriber** (any
//! program) that `subscribe`s and is then pushed each event.
//! **source** (a keyboard, mouse, or joystick/gamepad driver) that publishes events, and a
//! **subscriber** (any program) that subscribes and is then pushed each event.
//!
//! Two message shapes ride over one endpoint, tagged by `Operation`, exactly like the
//! The service handles several device classes over one endpoint. Each class has its own
//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); they all travel in a common
//! `InputEvent` envelope tagged with a `DeviceKind`, so the fan-out path is one code path
//! and a subscriber can take a mix of devices on a single stream. A subscriber declares
//! which classes it wants with a `device_mask`, and the service routes accordingly.
//!
//! Two message shapes ride over the endpoint, tagged by `Operation`, like the
//! [VFS protocol](../vfs/protocol.zig):
//!
//! - **subscribe / publish**: a synchronous `ipc_call` carrying a `Request`. `subscribe`
//! hands the service the subscriber's own endpoint as a capability (`send_cap`);
//! `publish` carries a `KeyEvent`. The reply is a `Reply`.
//! - **delivery**: the service pushes each `KeyEvent` to every subscriber with the
//! asynchronous `ipc_send` — no reply owed, and a dead subscriber can never stall the
//! broadcast (the reason the async primitive exists). The wire form is a bare
//! `KeyEvent`, received in the subscriber's buffer with `Received.isMessage()` set.
//! hands the service the subscriber's own endpoint as a capability (`send_cap`) and a
//! `device_mask`; `publish` carries an `InputEvent`. The reply is a `Reply`.
//! - **delivery**: the service pushes each `InputEvent` to every interested subscriber with
//! the asynchronous `ipc_send` — no reply owed, and a dead subscriber can never stall the
//! broadcast. Received in the subscriber's buffer with `Received.isMessage()` set.
//!
//! This is a danos-native contract; shared by the input service, the `runtime.input`
//! This is a danos-native contract, shared by the input service, the `runtime.input`
//! client helpers, and every source/subscriber. Everything fits one IPC message.
/// What happened to a key. `key_down`/`key_up` are the physical make/break; `key_press`
/// is the higher-level "a character was produced" event a source emits alongside a
/// `key_down` for keys that map to a character (carrying it in `KeyEvent.character`).
pub const EventKind = enum(u32) {
key_down = 0, // a key was pressed (make)
key_up = 1, // a key was released (break)
key_press = 2, // a character-producing press; `character` is the Unicode scalar
const std = @import("std");
/// The classes of input device the service fans out. Each names a typed event and a bit in
/// the subscription mask.
pub const DeviceKind = enum(u32) {
keyboard = 0,
mouse = 1,
joystick = 2, // joysticks and gamepads/controllers
};
/// One keyboard event, as broadcast to subscribers. Fixed layout (`extern`) because it
/// crosses the IPC boundary by memory copy. A hardware-independent `keycode` names the
/// physical key; `character` is the Unicode scalar for `key_press` (else 0); `modifiers`
/// is a bitmask of the shift/ctrl/alt state (`modifier_*`), 0 until a source tracks it.
/// Subscription-interest bits (`Request.device_mask`) — which device classes a subscriber
/// wants. OR them together, or use `device_all`.
pub const device_keyboard: u32 = 1 << 0;
pub const device_mouse: u32 = 1 << 1;
pub const device_joystick: u32 = 1 << 2;
pub const device_all: u32 = device_keyboard | device_mouse | device_joystick;
/// The subscription bit for a `DeviceKind` value (as it appears in `InputEvent.device`).
/// An unknown device maps to 0, so it matches no subscriber.
pub fn deviceBit(device: u32) u32 {
return switch (device) {
@intFromEnum(DeviceKind.keyboard) => device_keyboard,
@intFromEnum(DeviceKind.mouse) => device_mouse,
@intFromEnum(DeviceKind.joystick) => device_joystick,
else => 0,
};
}
// --- keyboard ---------------------------------------------------------------
/// What happened to a key. `key_down`/`key_up` are the physical make/break; `key_press`
/// is the higher-level "a character was produced", carrying it in `KeyEvent.character`.
pub const EventKind = enum(u32) {
key_down = 0,
key_up = 1,
key_press = 2,
};
/// One keyboard event. `keycode` names the physical key (layout-independent); `character`
/// is the Unicode scalar for `key_press` (else 0); `modifiers` is an OR of `modifier_*`.
pub const KeyEvent = extern struct {
kind: u32, // an EventKind
keycode: u32, // a Keycode — the physical key, layout-independent
keycode: u32, // a Keycode
character: u32, // Unicode scalar for key_press, else 0
modifiers: u32, // OR of modifier_* bits
modifiers: u32, // OR of modifier_*
};
/// Modifier bits for `KeyEvent.modifiers`.
pub const modifier_shift: u32 = 1 << 0;
pub const modifier_control: u32 = 1 << 1;
pub const modifier_alt: u32 = 1 << 2;
/// A minimal danos-native keycode namespace — enough for the synthetic source and to
/// show the shape. A real set (USB HID usage-style) fills in with the scancode decoder.
/// A minimal danos-native keycode namespace — enough for the synthetic source and to show
/// the shape. A real set (USB HID usage-style) fills in with the scancode decoder.
pub const Keycode = enum(u32) {
unknown = 0,
a = 4, // deliberately USB-HID-usage-aligned so a real decoder can extend this
@@ -55,18 +86,111 @@ pub const Keycode = enum(u32) {
_,
};
/// Which side of a request this is.
pub const Operation = enum(u32) {
subscribe = 0, // register the caller's endpoint (passed as send_cap) to receive events
publish = 1, // a source submits `event` to broadcast to every subscriber
// --- mouse ------------------------------------------------------------------
/// What a mouse event reports. `motion` carries relative `dx`/`dy`; `button_down`/`up`
/// name a button in `button`; `scroll` carries `scroll_x`/`scroll_y`.
pub const MouseEventKind = enum(u32) {
motion = 0,
button_down = 1,
button_up = 2,
scroll = 3,
};
/// Request header. For `subscribe`, `event` is ignored and the caller's receive endpoint
/// travels as the call's capability. For `publish`, `event` is the event to broadcast.
pub const mouse_button_left: u32 = 1 << 0;
pub const mouse_button_right: u32 = 1 << 1;
pub const mouse_button_middle: u32 = 1 << 2;
/// One mouse event. Relative motion (`dx`/`dy`) and wheel (`scroll_*`) are signed;
/// `buttons` is the current pressed-button bitmask (`mouse_button_*`).
pub const MouseEvent = extern struct {
kind: u32, // a MouseEventKind
button: u32, // the mouse_button_* bit for button_down/up, else 0
dx: i32, // relative X motion (.motion)
dy: i32, // relative Y motion (.motion)
scroll_x: i32, // horizontal wheel (.scroll)
scroll_y: i32, // vertical wheel (.scroll)
buttons: u32, // current pressed-button bitmask
};
// --- joystick / gamepad -----------------------------------------------------
/// What a joystick/gamepad event reports. `axis` carries a signed `value` on axis
/// `control`; `button_down`/`up` name a button index in `control`.
pub const JoystickEventKind = enum(u32) {
axis = 0,
button_down = 1,
button_up = 2,
};
/// One joystick/gamepad event. `control` is the axis index (`.axis`) or button index
/// (button events); `value` is the axis position (signed, e.g. -32768..32767) for `.axis`;
/// `buttons` is the current pressed-button bitmask.
pub const JoystickEvent = extern struct {
kind: u32, // a JoystickEventKind
control: u32, // axis index (.axis) or button index (button events)
value: i32, // axis value for .axis, else 0
buttons: u32, // current pressed-button bitmask
};
// --- the common envelope ----------------------------------------------------
/// The largest per-device event, so `InputEvent` can hold any of them inline.
pub const max_event_size: usize = @max(@sizeOf(KeyEvent), @max(@sizeOf(MouseEvent), @sizeOf(JoystickEvent)));
/// The tagged envelope broadcast to subscribers: a `DeviceKind` plus the raw bytes of the
/// matching per-device event. Decode it with `asKeyboard`/`asMouse`/`asJoystick` (each
/// returns null unless `device` matches), or build one with the `from*` constructors.
pub const InputEvent = extern struct {
device: u32, // a DeviceKind
_padding: u32 = 0,
data: [max_event_size]u8 = [_]u8{0} ** max_event_size,
pub fn asKeyboard(self: InputEvent) ?KeyEvent {
if (self.device != @intFromEnum(DeviceKind.keyboard)) return null;
return std.mem.bytesToValue(KeyEvent, self.data[0..@sizeOf(KeyEvent)]);
}
pub fn asMouse(self: InputEvent) ?MouseEvent {
if (self.device != @intFromEnum(DeviceKind.mouse)) return null;
return std.mem.bytesToValue(MouseEvent, self.data[0..@sizeOf(MouseEvent)]);
}
pub fn asJoystick(self: InputEvent) ?JoystickEvent {
if (self.device != @intFromEnum(DeviceKind.joystick)) return null;
return std.mem.bytesToValue(JoystickEvent, self.data[0..@sizeOf(JoystickEvent)]);
}
pub fn fromKeyboard(event: KeyEvent) InputEvent {
return pack(.keyboard, std.mem.asBytes(&event));
}
pub fn fromMouse(event: MouseEvent) InputEvent {
return pack(.mouse, std.mem.asBytes(&event));
}
pub fn fromJoystick(event: JoystickEvent) InputEvent {
return pack(.joystick, std.mem.asBytes(&event));
}
fn pack(device: DeviceKind, bytes: []const u8) InputEvent {
var self = InputEvent{ .device = @intFromEnum(device) };
@memcpy(self.data[0..bytes.len], bytes);
return self;
}
};
// --- request / reply --------------------------------------------------------
/// Which side of a request this is.
pub const Operation = enum(u32) {
subscribe = 0, // register the caller's endpoint (send_cap) for the classes in device_mask
publish = 1, // a source submits `event` to broadcast to interested subscribers
};
/// Request header. For `subscribe`, `device_mask` is the OR of `device_*` bits the caller
/// wants (0 means all) and the caller's receive endpoint travels as the call's capability;
/// `event` is ignored. For `publish`, `event` is the event to broadcast.
pub const Request = extern struct {
operation: u32, // an Operation
_padding: u32 = 0,
event: KeyEvent,
device_mask: u32 = 0, // subscribe: interested device classes (0 => all)
event: InputEvent = .{ .device = 0 },
};
/// Reply header. `status` is 0 on success or a negative errno.
@@ -77,11 +201,10 @@ pub const Reply = extern struct {
pub const request_size: usize = @sizeOf(Request);
pub const reply_size: usize = @sizeOf(Reply);
pub const event_size: usize = @sizeOf(KeyEvent);
pub const event_size: usize = @sizeOf(InputEvent);
comptime {
// The delivery path posts a bare KeyEvent through ipc_send, so it must fit an
// endpoint's async payload slot (abi has no dependency the other way, so the bound
// lives here where the wire form is defined: POST_MAXIMUM is 64).
if (event_size > 64) @compileError("KeyEvent must fit the ipc_send payload (POST_MAXIMUM)");
// The delivery path posts a bare InputEvent through ipc_send, so it must fit an
// endpoint's async payload slot (POST_MAXIMUM is 64).
if (event_size > 64) @compileError("InputEvent must fit the ipc_send payload (POST_MAXIMUM)");
}