summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-01 19:52:05 +0200
committersalmonik <salmonik@tuta.io>2026-08-01 19:52:05 +0200
commit819778f292ee4e1294603b98662e083e49e23463 (patch)
tree8df31b678432d2a89cac8e0d50f271a75a3882b5
parentrsnix v0.4.0: GUI input foundation — PS/2 mouse + scheduler message queues (diff)
downloadrs-dos-819778f292ee4e1294603b98662e083e49e23463.tar.gz
rs-dos-819778f292ee4e1294603b98662e083e49e23463.zip
rsnix v0.5.0: graphics layer + cursor compositor
The first visible GUI surface: real rect primitives, a single shadow→display push path, and an on-screen mouse cursor that can never race the text surface. gop: - fill_rect + push_rect(x,y,w,h): every raster op rasterises into the RAM shadow, then push_rect copies the rect to the display (write-only, still never read) and composites the cursor on top. blit_glyph and scroll shifts now route through it. - cursor overlay: 12x16 pointer (const-fn built from column lists) drawn white with a black offset outline; position in one AtomicU64 (x:high 32, y:low 32) — unpack masks the low word (a bare 'as usize' cast is a no-op on 64-bit). The cursor never enters the shadow, so erasing is just a shadow push of the old rect. - cursor_redraw after scroll shifts and once at spawn (the vt diff never re-blits idle cells). cursor task (task 2, spawned at boot when a mouse is present): - owns the PS/2 ring; polls every 10 ms, moves the cursor (clamped), tracks buttons, and forwards every event to a task's queue when 'mouse <id>' armed it (the window-manager input path). - /dev/mouse is now a position device (x/y/buttons snapshot); 'mouse' watches the cursor live (prints on change); 'cursor' prints one-shot state; 'gfx' draws a status-bar demo; 'gfx check' is an in-kernel pixel self-test (shadow readback, display readback, cursor overlay purity) — PASS/FAIL per stage. Tests (headless QEMU + monitor injection): - make test-gfx (new): all four compositor checks PASS. - make test-mouse (rewritten): cursor tracks mouse_move deltas, buttons ride along, /dev/mouse snapshot, live watch, real-event queue routing. - make test-msg: task numbering shifted (cursor is task 2; msgrecv is task 3) — updated, still PASS. - make test-disk regression re-passes. 0 build warnings.
Diffstat (limited to '')
-rw-r--r--CONTEXT.md74
-rw-r--r--Cargo.lock6
-rw-r--r--Cargo.toml2
-rw-r--r--Makefile5
-rw-r--r--rsdos/src/main.rs5
-rw-r--r--rsnix/src/cursor.rs71
-rw-r--r--rsnix/src/fs.rs37
-rw-r--r--rsnix/src/gop.rs268
-rw-r--r--rsnix/src/lib.rs1
-rwxr-xr-xscripts/test-gfx.sh114
-rwxr-xr-xscripts/test-mouse.sh125
-rwxr-xr-xscripts/test-msg.sh37
-rw-r--r--shell/src/lib.rs194
13 files changed, 791 insertions, 148 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index 9ceb81d..bfe0170 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -113,9 +113,10 @@ Workspace version is `a.b.c` (single version for all crates):
113 113
114Bump the workspace version in the root `Cargo.toml` when a change of 114Bump the workspace version in the root `Cargo.toml` when a change of
115that class lands — do not leave the version to drift. Current: 115that class lands — do not leave the version to drift. Current:
116`0.4.0` — the GUI input foundation (PS/2 mouse + scheduler message 116`0.5.0` — the GUI era landed (graphics layer + cursor compositor) as a
117queues) landed as a `b` bump; the GUI era itself is reserved for the 117`b` bump from the 0.4.0 input foundation; a future major GUI overhaul
118`a` bump (1.0.0). 118(graphics windows beyond the text surface) would be the `a` bump
119(1.0.0).
119 120
120## Project layout 121## Project layout
121 122
@@ -426,19 +427,24 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target:
426 relative** (`dx` + = right, `dy` + = down — the PS/2 Y-up wire 427 relative** (`dx` + = right, `dy` + = down — the PS/2 Y-up wire
427 convention is negated), buttons decoded; 9-bit sign extension via 428 convention is negated), buttons decoded; 9-bit sign extension via
428 the packet sign bits. 429 the packet sign bits.
429- `fs`: `/dev/mouse` char device — `cat /dev/mouse` drains up to 32 430- `fs`: `/dev/mouse` is now a **position device** — `cat /dev/mouse`
430 pending events (`dx=+N dy=+N buttons=L-R`), reports 431 prints the cursor's current state (`x=N y=N buttons=---`), since
431 `mouse: no PS/2 mouse` / `mouse: no events` otherwise. 432 the cursor task owns the PS/2 ring; `mouse: no PS/2 mouse`
432- shell: `mouse` command streams live events (10 ms poll via 433 otherwise.
433 `sleep_ms`) until ^C. 434- shell: `mouse` **watches the on-screen cursor** (x/y/buttons,
435 printed only on change, 10 ms poll) until ^C; `mouse <id>` also
436 routes every decoded event to that task's queue (`cursor still
437 tracks`, route cleared on exit). `cursor` prints a one-shot state
438 line (the parseable test surface).
434 439
435 Verified in QEMU (`make test-mouse`, headless, via the monitor's 440 Verified in QEMU (`make test-mouse`, headless, via the monitor's
436 `mouse_move`/`mouse_button` HMP commands): boot line `ps2: mouse 441 `mouse_move`/`mouse_button` HMP commands): the cursor starts centred,
437 ready`, `/dev/mouse` drains a pre-consumer move (`8,4`), the live 442 `mouse_move 20 7` moves it +20/+7 (asserted against `cursor` output),
438 stream decodes `mouse_move 20 7` → `dx=+20 dy=+7 buttons=---`, 443 `mouse_button 1` → `buttons=L--`, a held-button move (`-5,-3`)
439 `mouse_button 1` → `buttons=L--`, a held-button move 444 tracks position *and* keeps the button, release clears, `/dev/mouse`
440 (`-5,-3` while L down) → `dx=-5 dy=-3 buttons=L--`, release, 445 reports the same position snapshot, the live `mouse` watch prints the
441 and ^C stops the stream. `make test-disk` regression re-passes. 446 changed line and ^C stops it, and `mouse 3` routes a real move into
447 the msgrecv task's queue. `make test-disk` regression re-passes.
442 448
443✅ **Scheduler message queues (the GUI-era primitive from ADR 0003)** — 449✅ **Scheduler message queues (the GUI-era primitive from ADR 0003)** —
444 every task gets a static 16-slot ring of `TaskMsg` (4 KB in .bss, 450 every task gets a static 16-slot ring of `TaskMsg` (4 KB in .bss,
@@ -475,6 +481,46 @@ Workspace of four crates, all built for the `x86_64-unknown-uefi` target:
475 the shell's never-consumed queue), and `jobs` shows q=0 + task back 481 the shell's never-consumed queue), and `jobs` shows q=0 + task back
476 to `blocked` after draining. `make test-disk` regression re-passes. 482 to `blocked` after draining. `make test-disk` regression re-passes.
477 483
484✅ **Graphics layer + cursor compositor** — the first visible GUI
485 surface: real rect primitives, a shadow→display push path, and an
486 on-screen mouse cursor that never races the text surface:
487
488- `gop`: `fill_rect` + `push_rect(x,y,w,h)` — the **single display
489 write path**. Every raster op rasterises into the RAM shadow
490 first, then `push_rect` copies that rect to the display (write-
491 only; the display is still never *read*) and composites the
492 cursor on top. `blit_glyph` and scroll shifts now route through
493 it, so text redraws can never erase or corrupt the cursor. The
494 cursor lives *only* in the overlay — never in the shadow — which
495 makes save/restore impossible to get wrong: erasing = push the
496 old rect (the shadow simply doesn't contain the cursor there).
497- cursor bitmap: 12×16 pointer built by a const fn from explicit
498 column lists (hand-mangled bit literals were the one real bug
499 this round — twice), drawn white with a black offset outline
500 (+1,+1). Position lives in one `AtomicU64` (x:high 32, y:low 32)
501 so a relaxed load never sees a torn pair — *and* the unpack must
502 mask the low word (a bare `as usize` cast is a no-op on 64-bit
503 and returned the whole packed value).
504- `cursor` task (spawned at boot when the mouse is present, task 2):
505 polls the PS/2 ring every 10 ms, moves the cursor via
506 `cursor_move` (clamped to the screen; erase-old + draw-new are
507 both shadow pushes), tracks buttons, and forwards every event to
508 a task's queue when `mouse <id>` armed it. The cursor is drawn
509 once at spawn (the vt diff never re-blits idle cells) and stays
510 visible because every display write composites it; scrolls
511 re-overlay it after the bulk shift.
512- shell: `gfx` draws a status-bar demo scene (navy strip + RGB
513 boxes); `gfx check` is an in-kernel pixel self-test — draws a red
514 rect, reads it back from the shadow *and* the display, asserts
515 the cursor tip is white on the display but absent from the shadow
516 (overlay purity), and prints PASS/FAIL per stage.
517
518 Verified in QEMU (`make test-gfx`): `fill_rect/shadow: OK`,
519 `display push: OK`, `cursor overlay: OK`, `cursor not in shadow:
520 OK`, `gfx check: PASS`; the demo draws; the cursor tracks injected
521 mouse input (test-mouse) with the overlay intact. `make test-disk`
522 regression re-passes.
523
478## Known issues / perf notes 524## Known issues / perf notes
479 525
480- **Scroll cost** — scrolling still shifts the whole framebuffer (a 526- **Scroll cost** — scrolling still shifts the whole framebuffer (a
diff --git a/Cargo.lock b/Cargo.lock
index 444738e..19806d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
4 4
5[[package]] 5[[package]]
6name = "rsdos" 6name = "rsdos"
7version = "0.4.0" 7version = "0.5.0"
8dependencies = [ 8dependencies = [
9 "rsnix", 9 "rsnix",
10 "shell", 10 "shell",
@@ -19,11 +19,11 @@ dependencies = [
19 19
20[[package]] 20[[package]]
21name = "rsnix" 21name = "rsnix"
22version = "0.4.0" 22version = "0.5.0"
23 23
24[[package]] 24[[package]]
25name = "shell" 25name = "shell"
26version = "0.4.0" 26version = "0.5.0"
27dependencies = [ 27dependencies = [
28 "rsdosedit", 28 "rsdosedit",
29 "rsnix", 29 "rsnix",
diff --git a/Cargo.toml b/Cargo.toml
index 2864712..273966b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
3members = ["rsnix", "shell", "rsdos", "rsdosedit"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.4.0" 6version = "0.5.0"
7edition = "2024" 7edition = "2024"
8 8
9# no_std UEFI kernel: no unwinder available, so panic must abort. 9# no_std UEFI kernel: no unwinder available, so panic must abort.
diff --git a/Makefile b/Makefile
index f244251..9604893 100644
--- a/Makefile
+++ b/Makefile
@@ -14,7 +14,7 @@ export BOOT_EFI=$(ESP_DIR)/EFI/BOOT/BOOTX64.EFI
14# Create one with: make disk (or: ./scripts/mkdisk.sh) 14# Create one with: make disk (or: ./scripts/mkdisk.sh)
15export DISK_IMG ?= /tmp/disk.img 15export DISK_IMG ?= /tmp/disk.img
16 16
17.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-msg 17.PHONY: all run run-gui clean image debug release fmt bootloader boot-image disk disk-fresh inspect test-disk test-mouse test-gfx test-msg
18 18
19# ---- Build --------------------------------------------------------------- 19# ---- Build ---------------------------------------------------------------
20 20
@@ -118,6 +118,9 @@ test-disk:
118test-mouse: 118test-mouse:
119 ./scripts/test-mouse.sh 119 ./scripts/test-mouse.sh
120 120
121test-gfx:
122 ./scripts/test-gfx.sh
123
121# Automated scheduler message-queue round trip: msgrecv/send, real-input 124# Automated scheduler message-queue round trip: msgrecv/send, real-input
122# routing into a task's queue, queue-full boundary. 125# routing into a task's queue, queue-full boundary.
123test-msg: 126test-msg:
diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs
index fc47c82..13923ea 100644
--- a/rsdos/src/main.rs
+++ b/rsdos/src/main.rs
@@ -219,6 +219,11 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut
219 } 219 }
220 220
221 rsnix::serial_println!("boot: entering shell"); 221 rsnix::serial_println!("boot: entering shell");
222 if rsnix::ps2::mouse_present() {
223 rsnix::cursor::spawn();
224 } else {
225 rsnix::serial_println!("cursor: no mouse — overlay disabled");
226 }
222 shell::run(); 227 shell::run();
223} 228}
224 229
diff --git a/rsnix/src/cursor.rs b/rsnix/src/cursor.rs
new file mode 100644
index 0000000..18c9a1d
--- /dev/null
+++ b/rsnix/src/cursor.rs
@@ -0,0 +1,71 @@
1// ---------------------------------------------------------------------------
2// cursor — the mouse cursor task.
3//
4// One background task owns the PS/2 mouse: it polls the packet ring every
5// 10 ms, moves the on-screen cursor (gop's display overlay), tracks the
6// button state, and optionally *forwards* every event to another task's
7// message queue (`mouse <id>` sets the target) — the input-routing half
8// of the future window manager.
9//
10// The cursor itself never touches the text surface: `gop::push_rect`
11// composites it on top of every display write, so text scrolling and
12// cursor movement cannot corrupt each other (see gop.rs).
13// ---------------------------------------------------------------------------
14use core::sync::atomic::{AtomicUsize, Ordering};
15
16use crate::ps2::MouseEvent;
17use crate::sched::TaskMsg;
18
19/// Forward target task id, or `usize::MAX` = none.
20static FORWARD_TARGET: AtomicUsize = AtomicUsize::new(usize::MAX);
21
22/// Spawn the cursor task. Call after `sched::init`; only makes sense
23/// when the PS/2 mouse came up at boot. The cursor starts centred.
24pub fn spawn() -> Option<usize> {
25 let (w, h) = crate::gop::pixel_dims();
26 crate::gop::cursor_set(w / 2 - 6, h / 2 - 8);
27 // Draw it immediately: the vt diff only re-blits changed cells, so an
28 // idle centre cell would never push (and thus never composite) it.
29 crate::gop::cursor_redraw();
30 let id = crate::sched::spawn(task_main, "cursor")?;
31 crate::serial_println!(
32 "cursor: task {} — mouse poll + display overlay (forward target: none)",
33 id
34 );
35 Some(id)
36}
37
38/// Route decoded mouse events to a task's queue (the shell's `mouse
39/// <id>`). `None` stops forwarding.
40pub fn set_forward(target: Option<usize>) {
41 FORWARD_TARGET.store(target.unwrap_or(usize::MAX), Ordering::Relaxed);
42}
43
44fn forward_target() -> Option<usize> {
45 match FORWARD_TARGET.load(Ordering::Relaxed) {
46 usize::MAX => None,
47 id => Some(id),
48 }
49}
50
51fn task_main() {
52 loop {
53 while let Some(ev) = crate::ps2::try_poll_mouse() {
54 crate::gop::cursor_move(ev.dx, ev.dy);
55 crate::gop::cursor_set_buttons(btn_bits(&ev));
56 if let Some(id) = forward_target()
57 && crate::sched::send(id, TaskMsg::Mouse(ev)).is_err()
58 {
59 // The listener died mid-stream: stop routing (the
60 // cursor keeps tracking) rather than spam errors.
61 set_forward(None);
62 crate::serial_println!("cursor: forward target task {} gone — stopped routing", id);
63 }
64 }
65 crate::sched::sleep_ms(10);
66 }
67}
68
69fn btn_bits(ev: &MouseEvent) -> u8 {
70 (ev.left as u8) | ((ev.right as u8) << 1) | ((ev.middle as u8) << 2)
71}
diff --git a/rsnix/src/fs.rs b/rsnix/src/fs.rs
index 0b7a772..5fc6d7a 100644
--- a/rsnix/src/fs.rs
+++ b/rsnix/src/fs.rs
@@ -713,34 +713,23 @@ fn dev_text(dev: CharDevice, _pos: u64) -> String {
713 } 713 }
714} 714}
715 715
716/// Render `/dev/mouse`: drain every pending PS/2 event (up to 32) into one 716/// Render `/dev/mouse`: the current cursor state (position + buttons).
717/// line each, so `cat /dev/mouse` shows the movement that happened since 717/// The cursor task owns the PS/2 ring, so this is a position device —
718/// the last read. Falls back to a clear message when there is no device or 718/// the GUI-correct semantic — rather than an event drain.
719/// nothing pending.
720fn mouse_text() -> String { 719fn mouse_text() -> String {
721 if !crate::ps2::mouse_present() { 720 if !crate::ps2::mouse_present() {
722 return String::from("mouse: no PS/2 mouse\n"); 721 return String::from("mouse: no PS/2 mouse\n");
723 } 722 }
724 let mut s = String::new(); 723 let (x, y) = crate::gop::cursor_pos();
725 for _ in 0..32 { 724 let b = crate::gop::cursor_buttons();
726 let Some(ev) = crate::ps2::try_poll_mouse() else { 725 alloc::format!(
727 break; 726 "x={} y={} buttons={}{}{}\n",
728 }; 727 x,
729 let mut b = String::new(); 728 y,
730 b.push(if ev.left { 'L' } else { '-' }); 729 if b & 1 != 0 { 'L' } else { '-' },
731 b.push(if ev.right { 'R' } else { '-' }); 730 if b & 2 != 0 { 'R' } else { '-' },
732 b.push(if ev.middle { 'M' } else { '-' }); 731 if b & 4 != 0 { 'M' } else { '-' }
733 s.push_str(&alloc::format!( 732 )
734 "dx={:+} dy={:+} buttons={}\n",
735 ev.dx,
736 ev.dy,
737 b
738 ));
739 }
740 if s.is_empty() {
741 s.push_str("mouse: no events\n");
742 }
743 s
744} 733}
745 734
746// --- self-test ------------------------------------------------------------ 735// --- self-test ------------------------------------------------------------
diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs
index 0d24d7d..9dedcb8 100644
--- a/rsnix/src/gop.rs
+++ b/rsnix/src/gop.rs
@@ -10,11 +10,11 @@ use alloc::vec;
10use alloc::vec::Vec; 10use alloc::vec::Vec;
11use core::fmt; 11use core::fmt;
12use core::ptr; 12use core::ptr;
13use core::sync::atomic::{AtomicU8, AtomicU64, Ordering};
13 14
14/// Framebuffer state captured before ExitBootServices. 15/// Framebuffer state captured before ExitBootServices.
15pub struct Framebuffer { 16pub struct Framebuffer {
16 base: *mut u8, 17 base: *mut u8,
17 #[allow(dead_code)]
18 width: usize, 18 width: usize,
19 height: usize, 19 height: usize,
20 pitch: usize, // bytes per scan line 20 pitch: usize, // bytes per scan line
@@ -119,6 +119,242 @@ pub fn dims() -> (usize, usize) {
119 } 119 }
120} 120}
121 121
122/// (width, height) of the framebuffer in pixels.
123pub fn pixel_dims() -> (usize, usize) {
124 match fb().as_ref() {
125 Some(f) => (f.width, f.height),
126 None => (0, 0),
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Rect primitives + the push path
132// ---------------------------------------------------------------------------
133
134/// Copy a shadow rect to the display and composite the mouse cursor on
135/// top. Every display write goes through here (glyph blits, fills,
136/// scroll shifts), so the cursor — which lives *only* in this overlay,
137/// never in the shadow — stays on top of whatever the content is without
138/// any save/restore bookkeeping. The display is never read.
139pub(crate) fn push_rect(x: usize, y: usize, w: usize, h: usize) {
140 let Some(fb) = fb().as_ref() else {
141 return;
142 };
143 if unsafe { SHADOW.is_null() } || x >= fb.width || y >= fb.height {
144 return;
145 }
146 let w = w.min(fb.width - x);
147 let h = h.min(fb.height - y);
148 let row_words = fb.pitch / 4;
149 unsafe {
150 for dy in 0..h {
151 let off = (y + dy) * row_words + x;
152 ptr::copy_nonoverlapping(SHADOW.add(off), fb.base.add(off * 4) as *mut u32, w);
153 }
154 }
155 overlay_cursor(x, y, w, h);
156}
157
158/// Fill a rectangle in the shadow and push it (with cursor composited).
159pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) {
160 let Some(fb) = fb().as_ref() else {
161 return;
162 };
163 if x >= fb.width || y >= fb.height {
164 return;
165 }
166 let w = w.min(fb.width - x);
167 let h = h.min(fb.height - y);
168 if unsafe { SHADOW.is_null() } {
169 // Degraded: no backbuffer — write the display directly.
170 let row_words = fb.pitch / 4;
171 unsafe {
172 for dy in 0..h {
173 let off = (y + dy) * row_words + x;
174 for dx in 0..w {
175 ptr::write_volatile(fb.base.add((off + dx) * 4) as *mut u32, color);
176 }
177 }
178 }
179 return;
180 }
181 let row_words = fb.pitch / 4;
182 unsafe {
183 for dy in 0..h {
184 let off = (y + dy) * row_words + x;
185 for dx in 0..w {
186 *SHADOW.add(off + dx) = color;
187 }
188 }
189 }
190 push_rect(x, y, w, h);
191}
192
193/// Read one pixel back from the shadow (RAM; cheap). Test/demo use only.
194pub fn shadow_pixel(x: usize, y: usize) -> Option<u32> {
195 let fb = fb().as_ref()?;
196 if x >= fb.width || y >= fb.height || unsafe { SHADOW.is_null() } {
197 return None;
198 }
199 Some(unsafe { *SHADOW.add(y * (fb.pitch / 4) + x) })
200}
201
202/// Read one pixel back from the display (volatile; uncached on real
203/// hardware — test/demo use only, never on a hot path).
204pub fn display_pixel(x: usize, y: usize) -> Option<u32> {
205 let fb = fb().as_ref()?;
206 if x >= fb.width || y >= fb.height {
207 return None;
208 }
209 Some(unsafe { ptr::read_volatile(fb.base.add((y * (fb.pitch / 4) + x) * 4) as *mut u32) })
210}
211
212// ---------------------------------------------------------------------------
213// Mouse cursor — a display overlay, composited by `push_rect`.
214// ---------------------------------------------------------------------------
215
216/// Cursor bitmap: 12×16 classic pointer, 1 = opaque. The hotspot is the
217/// top-left corner of the bitmap; the arrow tip sits at (2, 0). Built by
218/// a const fn from explicit column lists (pixel column `dx` = bit `11-dx`)
219/// so the shape is auditable instead of hand-mangled bit literals.
220const CURSOR_W: usize = 12;
221const CURSOR_H: usize = 16;
222
223const fn bmp_row(cols: &[u8]) -> u16 {
224 let mut v: u16 = 0;
225 let mut i = 0;
226 while i < cols.len() {
227 v |= 1u16 << (11 - cols[i] as u16);
228 i += 1;
229 }
230 v
231}
232
233/// Head: triangle from a 1-px tip at (2,0) widening right to col 11.
234/// Tail: 2-px diagonal dropping down-right from the head's base.
235const CURSOR_BMP: [u16; CURSOR_H] = [
236 bmp_row(&[2]),
237 bmp_row(&[2, 3]),
238 bmp_row(&[2, 3, 4]),
239 bmp_row(&[2, 3, 4, 5]),
240 bmp_row(&[2, 3, 4, 5, 6]),
241 bmp_row(&[2, 3, 4, 5, 6, 7]),
242 bmp_row(&[2, 3, 4, 5, 6, 7, 8]),
243 bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9]),
244 bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9, 10]),
245 bmp_row(&[2, 3, 4, 5, 6, 7, 8, 9, 10, 11]),
246 bmp_row(&[8, 9]),
247 bmp_row(&[9, 10]),
248 bmp_row(&[9, 10]),
249 bmp_row(&[10, 11]),
250 bmp_row(&[10, 11]),
251 bmp_row(&[10, 11]),
252];
253
254/// Packed cursor position: x in the high 32 bits, y in the low 32, so a
255/// single relaxed load never sees a torn pair. Written by the cursor
256/// task, read by every push (any task).
257static CURSOR_POS: AtomicU64 = AtomicU64::new(0);
258static CURSOR_BUTTONS: AtomicU8 = AtomicU8::new(0);
259
260fn pack(x: usize, y: usize) -> u64 {
261 ((x as u64) << 32) | (y as u64)
262}
263
264/// Current cursor position (top-left of the bitmap).
265pub fn cursor_pos() -> (usize, usize) {
266 let v = CURSOR_POS.load(Ordering::Relaxed);
267 ((v >> 32) as usize, (v & 0xFFFF_FFFF) as usize)
268}
269
270/// Current button state: bit 0 = left, bit 1 = right, bit 2 = middle.
271pub fn cursor_buttons() -> u8 {
272 CURSOR_BUTTONS.load(Ordering::Relaxed)
273}
274
275pub fn cursor_set_buttons(b: u8) {
276 CURSOR_BUTTONS.store(b, Ordering::Relaxed);
277}
278
279/// Place the cursor (used at init; centre of the screen).
280pub fn cursor_set(x: usize, y: usize) {
281 CURSOR_POS.store(pack(x, y), Ordering::Relaxed);
282}
283
284/// Move the cursor by a relative delta, clamped to the screen, erasing
285/// the old position (a shadow push — the shadow never held the cursor)
286/// and drawing the new one. Returns the new position.
287pub fn cursor_move(dx: i16, dy: i16) -> (usize, usize) {
288 let Some(fb) = fb().as_ref() else {
289 return cursor_pos();
290 };
291 let (cx, cy) = cursor_pos();
292 let max_x = fb.width.saturating_sub(CURSOR_W);
293 let max_y = fb.height.saturating_sub(CURSOR_H);
294 let nx = (cx as i64 + dx as i64).clamp(0, max_x as i64) as usize;
295 let ny = (cy as i64 + dy as i64).clamp(0, max_y as i64) as usize;
296 if (nx, ny) == (cx, cy) {
297 return (cx, cy);
298 }
299 CURSOR_POS.store(pack(nx, ny), Ordering::Relaxed);
300 // Restore the old rect (overlay now composites at the new position).
301 push_rect(cx, cy, CURSOR_W, CURSOR_H);
302 // Draw at the new position.
303 push_rect(nx, ny, CURSOR_W, CURSOR_H);
304 (nx, ny)
305}
306
307/// If the cursor overlaps the given rect, composite it (black outline
308/// offset +1,+1, white body on top). Called by `push_rect`.
309fn overlay_cursor(x: usize, y: usize, w: usize, h: usize) {
310 let (cx, cy) = cursor_pos();
311 if x >= cx + CURSOR_W || y >= cy + CURSOR_H {
312 return;
313 }
314 if cx >= x + w || cy >= y + h {
315 return;
316 }
317 draw_bmp(cx + 1, cy + 1, rgb(0, 0, 0));
318 draw_bmp(cx, cy, rgb(255, 255, 255));
319}
320
321/// Re-composite the cursor where it is (after a scroll shift, which
322/// bypasses `push_rect`, and once at init so it is visible over idle
323/// cells the diff never re-blits).
324pub(crate) fn cursor_redraw() {
325 let (cx, cy) = cursor_pos();
326 draw_bmp(cx + 1, cy + 1, rgb(0, 0, 0));
327 draw_bmp(cx, cy, rgb(255, 255, 255));
328}
329
330/// Write the cursor bitmap at (x, y) in one colour — display only, the
331/// shadow is never touched (that is what makes the overlay race-free).
332fn draw_bmp(x: usize, y: usize, color: u32) {
333 let Some(fb) = fb().as_ref() else {
334 return;
335 };
336 let row_words = fb.pitch / 4;
337 unsafe {
338 for dy in 0..CURSOR_H {
339 let yy = y + dy;
340 if yy >= fb.height {
341 break;
342 }
343 let row = CURSOR_BMP[dy];
344 for dx in 0..CURSOR_W {
345 let xx = x + dx;
346 if xx >= fb.width {
347 break;
348 }
349 if row & (1u16 << (11 - dx)) != 0 {
350 let off = yy * row_words + xx;
351 ptr::write_volatile(fb.base.add(off * 4) as *mut u32, color);
352 }
353 }
354 }
355 }
356}
357
122/// Pack an (r, g, b) colour for the framebuffer's pixel format. 358/// Pack an (r, g, b) colour for the framebuffer's pixel format.
123pub fn rgb(r: u8, g: u8, b: u8) -> u32 { 359pub fn rgb(r: u8, g: u8, b: u8) -> u32 {
124 match fb().as_ref() { 360 match fb().as_ref() {
@@ -191,6 +427,9 @@ impl Framebuffer {
191 // coalesce well on WC framebuffers — far fewer bus 427 // coalesce well on WC framebuffers — far fewer bus
192 // transactions than per-pixel stores. 428 // transactions than per-pixel stores.
193 ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, words); 429 ptr::copy_nonoverlapping(SHADOW, self.base as *mut u32, words);
430 // The scroll copied the shadow (which never contains the
431 // cursor); re-composite it on top of the pushed frame.
432 crate::gop::cursor_redraw();
194 } 433 }
195 } 434 }
196} 435}
@@ -363,20 +602,35 @@ pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg:
363 let x = col * 8; 602 let x = col * 8;
364 let y = row * 16; 603 let y = row * 16;
365 604
605 if unsafe { SHADOW.is_null() } {
606 // Degraded: no backbuffer — write the display directly, no
607 // cursor overlay.
608 for cy in 0..16 {
609 let row_bits = data[cy];
610 for cx in 0..8 {
611 let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel;
612 let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg };
613 unsafe {
614 ptr::write_volatile(fb.base.add(offset) as *mut u32, px);
615 }
616 }
617 }
618 return;
619 }
620
621 // Normal path: rasterise into the RAM shadow, then one push
622 // copies the cell to the display and composites the cursor.
366 for cy in 0..16 { 623 for cy in 0..16 {
367 let row_bits = data[cy]; 624 let row_bits = data[cy];
368 for cx in 0..8 { 625 for cx in 0..8 {
369 let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel; 626 let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel;
627 let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg };
370 unsafe { 628 unsafe {
371 let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; 629 *SHADOW.add(offset / 4) = px;
372 // Display (volatile; the fb is MMIO-ish) + RAM shadow.
373 ptr::write_volatile(fb.base.add(offset) as *mut u32, px);
374 if !SHADOW.is_null() {
375 *SHADOW.add(offset / 4) = px;
376 }
377 } 630 }
378 } 631 }
379 } 632 }
633 push_rect(x, y, 8, 16);
380 } 634 }
381} 635}
382 636
diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs
index 08ac6c2..f4645a5 100644
--- a/rsnix/src/lib.rs
+++ b/rsnix/src/lib.rs
@@ -3,6 +3,7 @@
3extern crate alloc; 3extern crate alloc;
4 4
5pub mod ahci; 5pub mod ahci;
6pub mod cursor;
6pub mod fat; 7pub mod fat;
7pub mod font; 8pub mod font;
8pub mod fs; 9pub mod fs;
diff --git a/scripts/test-gfx.sh b/scripts/test-gfx.sh
new file mode 100755
index 0000000..a6e4016
--- /dev/null
+++ b/scripts/test-gfx.sh
@@ -0,0 +1,114 @@
1#!/usr/bin/env bash
2#
3# scripts/test-gfx.sh — automated graphics-layer verification.
4#
5# The cursor overlay compositor is the core of the GUI path: the shadow
6# holds true content, the display gets shadow pushes with the cursor
7# painted on top at every push, and the cursor never enters the shadow.
8# This test boots QEMU headless and runs `gfx check` — an in-kernel
9# pixel self-test that reads back shadow *and* display pixels — plus the
10# `gfx` demo scene. Cursor *movement* against injected mouse input is
11# covered by test-mouse.sh; here we prove the compositor math.
12#
13# Usage: ./scripts/test-gfx.sh
14# Env: OVMF, QEMU (same defaults as other test scripts)
15#
16set -euo pipefail
17
18SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
20cd "$PROJECT_DIR"
21
22OVMF="${OVMF:-/usr/share/edk2/x64/OVMF.4m.fd}"
23QEMU="${QEMU:-qemu-system-x86_64}"
24ESP_DIR="$PROJECT_DIR/esp"
25QPID=/tmp/rsdos-gfx-qpid
26SOCK=/tmp/rsdos-gfx-qmon.sock
27LOG=/tmp/rsdos-gfx-serial.log
28QLOG=/tmp/rsdos-gfx-qemu.log
29
30trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT
31
32fail() {
33 echo "FAIL: $1"
34 [ -f "$LOG" ] && tail -40 "$LOG"
35 exit 1
36}
37
38wait_log() {
39 local i=0
40 while [ "$i" -lt $((${3:-60} * 10)) ]; do
41 grep -q "$2" "$1" 2>/dev/null && return 0
42 sleep 0.1
43 i=$((i + 1))
44 done
45 return 1
46}
47
48wait_qemu_exit() {
49 local i=0
50 while [ "$i" -lt $((${1:-30} * 10)) ]; do
51 kill -0 "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || return 0
52 sleep 0.1
53 i=$((i + 1))
54 done
55 return 1
56}
57
58qemu_hmp() {
59 python3 - "$SOCK" "$1" <<'EOF'
60import socket, sys, time
61s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
62s.connect(sys.argv[1])
63s.sendall((sys.argv[2] + "\n").encode())
64time.sleep(0.4)
65EOF
66}
67
68echo "==> 1/5 build kernel (debug)"
69cargo build --package rsdos --target x86_64-unknown-uefi
70
71echo "==> 2/5 deploy to ESP"
72mkdir -p "$ESP_DIR/EFI/BOOT"
73cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI"
74
75echo "==> 3/5 boot QEMU (headless)"
76kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true
77sleep 1
78rm -f "$SOCK" "$LOG"
79"$QEMU" -bios "$OVMF" \
80 -drive file=fat:rw:"$ESP_DIR",format=raw \
81 -serial file:"$LOG" \
82 -monitor unix:"$SOCK",server,nowait \
83 -m 256M -machine q35 -cpu qemu64 -no-reboot -display none \
84 >"$QLOG" 2>&1 &
85echo $! >"$QPID"
86
87wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
88grep -aq "cursor: task " "$LOG" || fail "cursor task did not spawn"
89
90echo "==> 4/5 in-kernel pixel self-test (gfx check)"
91python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
92 "clear" \
93 "gfx check"
94sleep 1.5
95
96# Every sub-check must have printed OK.
97grep -aq "fill_rect/shadow: OK" "$LOG" || fail "shadow writeback failed"
98grep -aq "display push: OK" "$LOG" || fail "shadow→display push failed"
99grep -aq "cursor overlay: OK" "$LOG" || fail "cursor not composited on display"
100grep -aq "cursor not in shadow: OK" "$LOG" || fail "cursor leaked into the shadow"
101grep -aq "gfx check: PASS" "$LOG" || fail "gfx check did not PASS"
102grep -aq "gfx check: FAIL" "$LOG" && fail "gfx check reported FAIL"
103
104echo "==> 5/5 demo scene + poweroff"
105python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
106 "gfx" \
107 "poweroff"
108wait_qemu_exit 30 || fail "QEMU did not exit after poweroff"
109
110grep -aq "gfx: drew status bar" "$LOG" || fail "gfx demo did not draw"
111
112echo ""
113echo "PASS — graphics layer OK (shadow, display push, cursor overlay)"
114echo " compositor: shadow=content, display=push+cursor, cursor never in shadow"
diff --git a/scripts/test-mouse.sh b/scripts/test-mouse.sh
index ed4df44..e58d474 100755
--- a/scripts/test-mouse.sh
+++ b/scripts/test-mouse.sh
@@ -1,20 +1,19 @@
1#!/usr/bin/env bash 1#!/usr/bin/env bash
2# 2#
3# scripts/test-mouse.sh — automated PS/2 mouse round trip. 3# scripts/test-mouse.sh — automated mouse round trip (cursor model).
4# 4#
5# Builds the kernel, boots QEMU headless, injects mouse movement and 5# Since v0.5.0 the cursor task owns the PS/2 ring: mouse input shows up
6# button presses through the monitor's `mouse_move` / `mouse_button` 6# as *cursor position + buttons* on the screen, not as an event drain.
7# commands, and verifies the guest decoded them via IRQ12 → ring → 7# This script boots QEMU headless, injects movement/buttons via the
8# packet decoder. Covers both surfaces: 8# monitor, and verifies:
9# - `cat /dev/mouse` (VFS snapshot of pending events) 9# - the `cursor` command reports a position that moves with `mouse_move`
10# - `mouse` (live streaming command, stopped by ^C) 10# - button presses ride along (L while held)
11# - `/dev/mouse` is a position device (one snapshot line)
12# - the `mouse` watch command prints live changes and stops on ^C
13# - a `mouse <id>` forward routes real events to a task's queue
11# 14#
12# Usage: 15# Usage: ./scripts/test-mouse.sh
13# ./scripts/test-mouse.sh 16# Env: OVMF, QEMU (same defaults as other test scripts)
14#
15# Environment:
16# OVMF firmware (default: /usr/share/edk2/x64/OVMF.4m.fd)
17# QEMU qemu binary (default: qemu-system-x86_64)
18# 17#
19set -euo pipefail 18set -euo pipefail
20 19
@@ -30,16 +29,14 @@ SOCK=/tmp/rsdos-mouse-qmon.sock
30LOG=/tmp/rsdos-mouse-serial.log 29LOG=/tmp/rsdos-mouse-serial.log
31QLOG=/tmp/rsdos-mouse-qemu.log 30QLOG=/tmp/rsdos-mouse-qemu.log
32 31
33# Never leave a QEMU behind.
34trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT 32trap 'kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true' EXIT
35 33
36fail() { 34fail() {
37 echo "FAIL: $1" 35 echo "FAIL: $1"
38 [ -f "$LOG" ] && tail -20 "$LOG" 36 [ -f "$LOG" ] && tail -30 "$LOG"
39 exit 1 37 exit 1
40} 38}
41 39
42# wait_log FILE PATTERN TIMEOUT-SECONDS
43wait_log() { 40wait_log() {
44 local i=0 41 local i=0
45 while [ "$i" -lt $((${3:-60} * 10)) ]; do 42 while [ "$i" -lt $((${3:-60} * 10)) ]; do
@@ -50,7 +47,6 @@ wait_log() {
50 return 1 47 return 1
51} 48}
52 49
53# wait_qemu_exit TIMEOUT-SECONDS
54wait_qemu_exit() { 50wait_qemu_exit() {
55 local i=0 51 local i=0
56 while [ "$i" -lt $((${1:-30} * 10)) ]; do 52 while [ "$i" -lt $((${1:-30} * 10)) ]; do
@@ -61,7 +57,6 @@ wait_qemu_exit() {
61 return 1 57 return 1
62} 58}
63 59
64# qemu_hmp — send one raw HMP command to the monitor socket.
65qemu_hmp() { 60qemu_hmp() {
66 python3 - "$SOCK" "$1" <<'EOF' 61 python3 - "$SOCK" "$1" <<'EOF'
67import socket, sys, time 62import socket, sys, time
@@ -72,14 +67,21 @@ time.sleep(0.4)
72EOF 67EOF
73} 68}
74 69
75echo "==> 1/5 build kernel (debug)" 70# cursor_state — run `cursor`, return "x y buttons" of the LAST occurrence.
71cursor_state() {
72 python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "cursor"
73 sleep 0.5
74 sed -n 's/.*cursor: x=\([0-9]*\) y=\([0-9]*\) buttons=\([A-Za-z-]*\).*/\1 \2 \3/p' "$LOG" | tail -1
75}
76
77echo "==> 1/6 build kernel (debug)"
76cargo build --package rsdos --target x86_64-unknown-uefi 78cargo build --package rsdos --target x86_64-unknown-uefi
77 79
78echo "==> 2/5 deploy to ESP" 80echo "==> 2/6 deploy to ESP"
79mkdir -p "$ESP_DIR/EFI/BOOT" 81mkdir -p "$ESP_DIR/EFI/BOOT"
80cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI" 82cp target/x86_64-unknown-uefi/debug/rsdos.efi "$ESP_DIR/EFI/BOOT/BOOTX64.EFI"
81 83
82echo "==> 3/5 boot QEMU (headless)" 84echo "==> 3/6 boot QEMU (headless)"
83kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true 85kill "$(cat "$QPID" 2>/dev/null)" 2>/dev/null || true
84sleep 1 86sleep 1
85rm -f "$SOCK" "$LOG" 87rm -f "$SOCK" "$LOG"
@@ -93,38 +95,73 @@ echo $! >"$QPID"
93 95
94wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell" 96wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
95grep -aq "ps2: mouse ready" "$LOG" || fail "mouse driver did not come up at boot" 97grep -aq "ps2: mouse ready" "$LOG" || fail "mouse driver did not come up at boot"
98grep -aq "cursor: task " "$LOG" || fail "cursor task did not spawn"
96 99
97# Seed a move *before* any consumer runs, so `cat /dev/mouse` has a 100echo "==> 4/6 cursor position tracks injected movement"
98# queued event to drain (exercises the VFS snapshot path). 101INIT=$(cursor_state)
99qemu_hmp "mouse_move 8 4" 102[ -n "$INIT" ] || fail "no initial cursor position"
100 103IX=$(echo "$INIT" | awk '{print $1}')
101echo "==> 4/5 drive the shell (cat /dev/mouse, mouse stream, poweroff)" 104IY=$(echo "$INIT" | awk '{print $2}')
102python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ 105IB=$(echo "$INIT" | awk '{print $3}')
103 "cat /dev/mouse" \ 106echo " initial: x=$IX y=$IY buttons=$IB"
104 "mouse" 107[ "$IB" = "---" ] || fail "expected no buttons at boot, got $IB"
105 108
106# While the `mouse` stream is live: a move, a left-press, a move with the
107# button held, then release — the button state must ride along on the
108# movement packet.
109qemu_hmp "mouse_move 20 7" 109qemu_hmp "mouse_move 20 7"
110S1=$(cursor_state)
111X1=$(echo "$S1" | awk '{print $1}')
112Y1=$(echo "$S1" | awk '{print $2}')
113[ "$X1" -eq $((IX + 20)) ] || fail "x did not move +20 (was $IX, now $X1)"
114[ "$Y1" -eq $((IY + 7)) ] || fail "y did not move +7 (was $IY, now $Y1)"
115
116echo "==> 5/6 buttons ride along; /dev/mouse is a position device"
110qemu_hmp "mouse_button 1" 117qemu_hmp "mouse_button 1"
118S2=$(cursor_state)
119B2=$(echo "$S2" | awk '{print $3}')
120[ "$B2" = "L--" ] || fail "left press not seen (got $B2)"
121
111qemu_hmp "mouse_move -5 -3" 122qemu_hmp "mouse_move -5 -3"
123S3=$(cursor_state)
124X3=$(echo "$S3" | awk '{print $1}')
125Y3=$(echo "$S3" | awk '{print $2}')
126B3=$(echo "$S3" | awk '{print $3}')
127[ "$X3" -eq $((X1 - 5)) ] || fail "held move x wrong ($X1 -5 = $((X1 - 5)), got $X3)"
128[ "$Y3" -eq $((Y1 - 3)) ] || fail "held move y wrong ($Y1 -3 = $((Y1 - 3)), got $Y3)"
129[ "$B3" = "L--" ] || fail "button state dropped during held move (got $B3)"
130
112qemu_hmp "mouse_button 0" 131qemu_hmp "mouse_button 0"
113qemu_hmp "sendkey ctrl-c" 132S4=$(cursor_state)
133B4=$(echo "$S4" | awk '{print $3}')
134[ "$B4" = "---" ] || fail "release not seen (got $B4)"
135
136python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "cat /dev/mouse"
137sleep 0.5
138grep -aq "x=$X3 y=$Y3 buttons=---" "$LOG" || fail "/dev/mouse does not report position snapshot (x=$X3 y=$Y3)"
139grep -aq "mouse: no events" "$LOG" && fail "/dev/mouse is still an event drain"
114 140
141echo "==> 6/6 live watch + forwarding + poweroff"
115python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ 142python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
116 "poweroff" 143 "msgrecv" \
144 "mouse 3"
117 145
118echo "==> 5/5 wait for shutdown + verify" 146# Live watch running: a move must appear as a changed line.
119wait_qemu_exit 30 || fail "QEMU did not exit after poweroff" 147qemu_hmp "mouse_move 3 2"
148# Forwarding live: the same move must reach the msgrecv task (spawned as
149# task 3; task 2 is the cursor task itself).
150sleep 1
151qemu_hmp "sendkey ctrl-c"
152
153grep -aq "mouse: routing events to task 3" "$LOG" || fail "mouse 3 did not arm routing"
154grep -aq "msgrecv\[3\]: Mouse(+" "$LOG" || fail "task 3 queue did not receive the routed Mouse event"
155# Watch line: x/y/buttons format (any occurrence after watching started).
156grep -aq "mouse: x=$((X3 + 3)) y=$((Y3 + 2)) buttons=---" "$LOG" || fail "watch did not print the moved cursor (x=$((X3+3)) y=$((Y3+2)))"
157grep -aq "mouse: stopped" "$LOG" || fail "^C did not stop the mouse watch"
120 158
121grep -aq "ps2: mouse ready (IRQ12, stream mode)" "$LOG" || fail "no mouse-ready boot line" 159python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "poweroff"
122grep -aq "dx=+8 dy=+4 buttons=---" "$LOG" || fail "/dev/mouse did not drain the queued move (dx=+8 dy=+4)" 160
123grep -aq "dx=+20 dy=+7 buttons=---" "$LOG" || fail "mouse stream missed mouse_move 20 7" 161echo "==> done: wait for shutdown + verify"
124grep -aq "dx=+0 dy=+0 buttons=L--" "$LOG" || fail "left-button press did not decode" 162wait_qemu_exit 30 || fail "QEMU did not exit after poweroff"
125grep -aq "dx=-5 dy=-3 buttons=L--" "$LOG" || fail "held-button movement wrong (dx=-5 dy=-3 L)"
126grep -aq "mouse: stopped" "$LOG" || fail "^C did not stop the mouse stream"
127 163
128echo "" 164echo ""
129echo "PASS — PS/2 mouse round trip OK (IRQ12, /dev/mouse, live stream, buttons)" 165echo "PASS — mouse round trip OK (cursor task, /dev/mouse, watch, forward)"
130echo " events: 8,4 · 20,7 · L-press · -5,-3 held · release" 166echo " moves: +20,+7 · -5,-3 held · +3,+2 watch"
167echo " path: IRQ12 → ring → cursor task → overlay + queue"
diff --git a/scripts/test-msg.sh b/scripts/test-msg.sh
index 0bd1c81..d81d8d7 100755
--- a/scripts/test-msg.sh
+++ b/scripts/test-msg.sh
@@ -93,20 +93,21 @@ echo $! >"$QPID"
93wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell" 93wait_log "$LOG" "boot: entering shell" 90 || fail "kernel never reached the shell"
94 94
95echo "==> 4/5 drive the shell (msgrecv / send / mouse routing / queue-full)" 95echo "==> 4/5 drive the shell (msgrecv / send / mouse routing / queue-full)"
96# msgrecv spawns as task 3 (task 2 is the cursor task).
96python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \ 97python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
97 "msgrecv" \ 98 "msgrecv" \
98 "send 2 user 42" \ 99 "send 3 user 42" \
99 "send 2 timer" \ 100 "send 3 timer" \
100 "send 2 key enter" \ 101 "send 3 key enter" \
101 "send 2 key ctrl-c" \ 102 "send 3 key ctrl-c" \
102 "send 2 key f3" \ 103 "send 3 key f3" \
103 "send 2 mouse 5 -3 3" \ 104 "send 3 mouse 5 -3 3" \
104 "sleep 800" \ 105 "sleep 800" \
105 "jobs" 106 "jobs"
106 107
107# Real IRQ12 events through the queue: start routing to task 2, inject 108# Real IRQ12 events through the queue: start routing to task 3, inject
108# moves + a left press, stop. 109# moves + a left press, stop.
109python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "mouse 2" 110python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" "mouse 3"
110qemu_hmp "mouse_move 20 7" 111qemu_hmp "mouse_move 20 7"
111qemu_hmp "mouse_button 1" 112qemu_hmp "mouse_button 1"
112qemu_hmp "mouse_move -5 -3" 113qemu_hmp "mouse_move -5 -3"
@@ -140,20 +141,20 @@ python3 "$SCRIPT_DIR/sendkeys.py" --sock "$SOCK" \
140echo "==> 5/5 wait for shutdown + verify" 141echo "==> 5/5 wait for shutdown + verify"
141wait_qemu_exit 30 || fail "QEMU did not exit after poweroff" 142wait_qemu_exit 30 || fail "QEMU did not exit after poweroff"
142 143
143grep -aq "msgrecv: task 2 listening" "$LOG" || fail "msgrecv did not spawn" 144grep -aq "msgrecv: task 3 listening" "$LOG" || fail "msgrecv did not spawn"
144grep -aq "msgrecv\[2\]: User(42)" "$LOG" || fail "user message missing" 145grep -aq "msgrecv\[3\]: User(42)" "$LOG" || fail "user message missing"
145grep -aq "msgrecv\[2\]: Timer" "$LOG" || fail "timer message missing" 146grep -aq "msgrecv\[3\]: Timer" "$LOG" || fail "timer message missing"
146grep -aq "msgrecv\[2\]: Key(Enter)" "$LOG" || fail "key enter missing" 147grep -aq "msgrecv\[3\]: Key(Enter)" "$LOG" || fail "key enter missing"
147grep -aq "msgrecv\[2\]: Key(Ctrl-c)" "$LOG" || fail "key ctrl-c missing" 148grep -aq "msgrecv\[3\]: Key(Ctrl-c)" "$LOG" || fail "key ctrl-c missing"
148grep -aq "msgrecv\[2\]: Key(F3)" "$LOG" || fail "key f3 missing" 149grep -aq "msgrecv\[3\]: Key(F3)" "$LOG" || fail "key f3 missing"
149grep -aq "msgrecv\[2\]: Mouse(+5,-3 L R)" "$LOG" || fail "synthesized mouse message missing" 150grep -aq "msgrecv\[3\]: Mouse(+5,-3 L R)" "$LOG" || fail "synthesized mouse message missing"
150# Real IRQ12 events routed through the queue (button held across the move). 151# Real IRQ12 events routed through the queue (button held across the move).
151grep -aq "msgrecv\[2\]: Mouse(+20,+7" "$LOG" || fail "real mouse move 20,7 not routed to queue" 152grep -aq "msgrecv\[3\]: Mouse(+20,+7" "$LOG" || fail "real mouse move 20,7 not routed to queue"
152grep -aq "msgrecv\[2\]: Mouse(-5,-3 L" "$LOG" || fail "held-button routed move missing" 153grep -aq "msgrecv\[3\]: Mouse(-5,-3 L" "$LOG" || fail "held-button routed move missing"
153grep -aq "queue full" "$LOG" || fail "17th send did not report queue full" 154grep -aq "queue full" "$LOG" || fail "17th send did not report queue full"
154grep -aq "send: unknown type" "$LOG" && fail "a bad send slipped through (expected none)" 155grep -aq "send: unknown type" "$LOG" && fail "a bad send slipped through (expected none)"
155 156
156echo "" 157echo ""
157echo "PASS — message queues OK (all types, ordering, real-input routing, queue-full)" 158echo "PASS — message queues OK (all types, ordering, real-input routing, queue-full)"
158echo " sent: user · timer · key(enter,ctrl-c,f3) · mouse · 16+1 queue-full" 159echo " sent: user · timer · key(enter,ctrl-c,f3) · mouse · 16+1 queue-full"
159echo " routed: mouse_move 20,7 → queue → msgrecv[2]" 160echo " routed: mouse_move 20,7 → queue → msgrecv[3]"
diff --git a/shell/src/lib.rs b/shell/src/lib.rs
index c18d319..e1d54c8 100644
--- a/shell/src/lib.rs
+++ b/shell/src/lib.rs
@@ -704,6 +704,8 @@ write `{} <path> TIL <marker>`",
704 "pci" => cmd_pci(&parsed), 704 "pci" => cmd_pci(&parsed),
705 "rescan" => cmd_rescan(&parsed), 705 "rescan" => cmd_rescan(&parsed),
706 "mouse" => cmd_mouse(&parsed), 706 "mouse" => cmd_mouse(&parsed),
707 "cursor" => cmd_cursor(&parsed),
708 "gfx" => cmd_gfx(&parsed),
707 "ls" => cmd_ls(&parsed), 709 "ls" => cmd_ls(&parsed),
708 "cat" => cmd_cat(&parsed), 710 "cat" => cmd_cat(&parsed),
709 "klog" => cmd_klog(&parsed), 711 "klog" => cmd_klog(&parsed),
@@ -741,8 +743,11 @@ fn cmd_help(_p: &Parsed) {
741 date — show current date/time (RTC) 743 date — show current date/time (RTC)
742 pci — list PCI devices 744 pci — list PCI devices
743 rescan — re-scan for hotplugged/removed disks (also runs automatically) 745 rescan — re-scan for hotplugged/removed disks (also runs automatically)
744 mouse — live mouse events (dx/dy/buttons); ^C stops 746 cursor — report the on-screen mouse cursor (x, y, buttons)
745 mouse <id> — post decoded mouse events to task <id>'s queue (input routing) 747 mouse — watch the cursor live (x/y/buttons); ^C stops
748 mouse <id> — route mouse events to task <id>'s queue (cursor still tracks)
749 gfx — demo: draw a status bar + RGB boxes (bottom strip)
750 gfx check — pixel self-test: shadow, display push, cursor overlay
746 bg <name> — spawn a background task (spinner, ticker, counter, boom, overflow) 751 bg <name> — spawn a background task (spinner, ticker, counter, boom, overflow)
747 msgrecv — spawn a task that blocks on its message queue and logs every 752 msgrecv — spawn a task that blocks on its message queue and logs every
748 message to serial (ids from `jobs`) 753 message to serial (ids from `jobs`)
@@ -1546,13 +1551,11 @@ fn cmd_rescan(_p: &Parsed) {
1546 } 1551 }
1547} 1552}
1548 1553
1549/// Live mouse event stream: print each decoded packet (dx/dy/buttons) as 1554/// Mouse front-end. The cursor task owns the PS/2 ring, so this
1550/// it arrives until ^C. With an optional task id, events are **posted to 1555/// command *watches the on-screen cursor* (position + buttons, printed
1551/// that task's message queue** instead of printed — the input-routing 1556/// only on change) until ^C. With a task id it also routes every event
1552/// path the window manager will use (`mouse <id>` + `msgrecv` proves the 1557/// to that task's queue — the window-manager input path — clearing the
1553/// whole chain: IRQ12 → ring → decoder → queue → blocked task). Polls 1558/// route on exit.
1554/// the PS/2 ring every 10 ms (via `sleep_ms`, which only blocks the
1555/// shell task) so background tasks keep running.
1556fn cmd_mouse(p: &Parsed) { 1559fn cmd_mouse(p: &Parsed) {
1557 let target: Option<usize> = p.args.first().and_then(|s| s.parse::<usize>().ok()); 1560 let target: Option<usize> = p.args.first().and_then(|s| s.parse::<usize>().ok());
1558 if let Some(id) = target { 1561 if let Some(id) = target {
@@ -1560,48 +1563,167 @@ fn cmd_mouse(p: &Parsed) {
1560 term_err(&alloc::format!("mouse: no such task {}", id)); 1563 term_err(&alloc::format!("mouse: no such task {}", id));
1561 return; 1564 return;
1562 } 1565 }
1566 rsnix::cursor::set_forward(Some(id));
1563 term_println(&alloc::format!( 1567 term_println(&alloc::format!(
1564 "mouse: posting events to task {} — ^C to stop", 1568 "mouse: routing events to task {} (cursor still tracks) — ^C to stop",
1565 id 1569 id
1566 )); 1570 ));
1571 } else if !rsnix::ps2::mouse_present() {
1572 term_err("mouse: no PS/2 mouse at boot — cat /dev/mouse still works");
1573 return;
1567 } else { 1574 } else {
1568 if !rsnix::ps2::mouse_present() { 1575 term_println("mouse: watching cursor — move the mouse, ^C to stop");
1569 term_err("mouse: no PS/2 mouse at boot — cat /dev/mouse still works");
1570 return;
1571 }
1572 term_println("mouse: streaming events — ^C to stop");
1573 } 1576 }
1577 let mut last = (usize::MAX, usize::MAX, u8::MAX);
1574 loop { 1578 loop {
1575 if tty::interrupt_pressed() { 1579 if tty::interrupt_pressed() {
1580 rsnix::cursor::set_forward(None);
1576 term_println("mouse: stopped"); 1581 term_println("mouse: stopped");
1577 return; 1582 return;
1578 } 1583 }
1579 while let Some(ev) = rsnix::ps2::try_poll_mouse() { 1584 let (x, y, b) = (
1580 match target { 1585 rsnix::gop::cursor_pos().0,
1581 Some(id) => { 1586 rsnix::gop::cursor_pos().1,
1582 if let Err(e) = rsnix::sched::send(id, rsnix::sched::TaskMsg::Mouse(ev)) { 1587 rsnix::gop::cursor_buttons(),
1583 term_err(&alloc::format!("mouse: {}", e)); 1588 );
1584 return; 1589 if (x, y, b) != last {
1585 } 1590 last = (x, y, b);
1586 } 1591 term_println(&alloc::format!(
1587 None => { 1592 "mouse: x={} y={} buttons={}{}{}",
1588 let mut b = String::new(); 1593 x,
1589 b.push(if ev.left { 'L' } else { '-' }); 1594 y,
1590 b.push(if ev.right { 'R' } else { '-' }); 1595 if b & 1 != 0 { 'L' } else { '-' },
1591 b.push(if ev.middle { 'M' } else { '-' }); 1596 if b & 2 != 0 { 'R' } else { '-' },
1592 term_println(&alloc::format!( 1597 if b & 4 != 0 { 'M' } else { '-' }
1593 "dx={:+} dy={:+} buttons={}", 1598 ));
1594 ev.dx,
1595 ev.dy,
1596 b
1597 ));
1598 }
1599 }
1600 } 1599 }
1601 rsnix::sched::sleep_ms(10); 1600 rsnix::sched::sleep_ms(10);
1602 } 1601 }
1603} 1602}
1604 1603
1604/// One-shot cursor state (parseable; used by the test harness).
1605fn cmd_cursor(_p: &Parsed) {
1606 let (x, y) = rsnix::gop::cursor_pos();
1607 let b = rsnix::gop::cursor_buttons();
1608 term_println(&alloc::format!(
1609 "cursor: x={} y={} buttons={}{}{}",
1610 x,
1611 y,
1612 if b & 1 != 0 { 'L' } else { '-' },
1613 if b & 2 != 0 { 'R' } else { '-' },
1614 if b & 4 != 0 { 'M' } else { '-' }
1615 ));
1616}
1617
1618/// Graphics primitives demo + pixel self-test (shadow + display + cursor
1619/// overlay readbacks). `gfx` / `gfx demo` draws a status-bar scene;
1620/// `gfx check` runs the readback assertions (run after `clear` for a
1621/// predictable background).
1622fn cmd_gfx(p: &Parsed) {
1623 match p.args.first().map(|s| s.as_str()) {
1624 Some("check") => gfx_check(),
1625 _ => gfx_demo(),
1626 }
1627}
1628
1629fn gfx_demo() {
1630 let (w, h) = rsnix::gop::pixel_dims();
1631 if w == 0 {
1632 term_err("gfx: no framebuffer");
1633 return;
1634 }
1635 // Status-bar strip across the bottom, with three RGB boxes.
1636 rsnix::gop::fill_rect(0, h - 40, w, 40, rsnix::gop::rgb(0, 0, 128));
1637 rsnix::gop::fill_rect(20, h - 32, 60, 24, rsnix::gop::rgb(255, 0, 0));
1638 rsnix::gop::fill_rect(90, h - 32, 60, 24, rsnix::gop::rgb(0, 255, 0));
1639 rsnix::gop::fill_rect(160, h - 32, 60, 24, rsnix::gop::rgb(255, 255, 0));
1640 term_println(&alloc::format!(
1641 "gfx: drew status bar + RGB boxes ({}x{} px bottom strip)",
1642 w,
1643 h
1644 ));
1645}
1646
1647fn gfx_check() {
1648 let (w, _h) = rsnix::gop::pixel_dims();
1649 if w == 0 {
1650 term_err("gfx: no framebuffer");
1651 return;
1652 }
1653 let mut ok = true;
1654 let mut report = String::new();
1655
1656 // 1. fill_rect lands in the shadow.
1657 let (rx, ry, rw, rh) = (8usize, 8usize, 40usize, 16usize);
1658 let red = rsnix::gop::rgb(255, 0, 0);
1659 rsnix::gop::fill_rect(rx, ry, rw, rh, red);
1660 match rsnix::gop::shadow_pixel(rx + rw / 2, ry + rh / 2) {
1661 Some(p) if p == red => report.push_str("fill_rect/shadow: OK\n"),
1662 Some(p) => {
1663 ok = false;
1664 report.push_str(&alloc::format!("fill_rect/shadow: FAIL ({:#x})\n", p));
1665 }
1666 None => {
1667 ok = false;
1668 report.push_str("fill_rect/shadow: FAIL (no shadow)\n");
1669 }
1670 }
1671 // 2. the push reached the display.
1672 match rsnix::gop::display_pixel(rx + rw / 2, ry + rh / 2) {
1673 Some(p) if p == red => report.push_str("display push: OK\n"),
1674 Some(p) => {
1675 ok = false;
1676 report.push_str(&alloc::format!("display push: FAIL ({:#x})\n", p));
1677 }
1678 None => {
1679 ok = false;
1680 report.push_str("display push: FAIL (readback)\n");
1681 }
1682 }
1683 // 3. cursor overlay: the arrow tip is white on the display…
1684 let (cx, cy) = rsnix::gop::cursor_pos();
1685 let (dw, dh) = rsnix::gop::pixel_dims();
1686 let white = rsnix::gop::rgb(255, 255, 255);
1687 let tip = rsnix::gop::display_pixel(cx + 2, cy);
1688 let out = rsnix::gop::display_pixel(cx, cy);
1689 match (tip, out) {
1690 (Some(t), Some(o)) if t == white && o != white => report.push_str("cursor overlay: OK\n"),
1691 (Some(t), Some(o)) => {
1692 ok = false;
1693 report.push_str(&alloc::format!(
1694 "cursor overlay: FAIL (tip={:#x} outside={:#x})\n",
1695 t,
1696 o
1697 ));
1698 }
1699 _ => {
1700 ok = false;
1701 report.push_str(&alloc::format!(
1702 "cursor overlay: FAIL (readback cx={} cy={} dims={}x{})\n",
1703 cx,
1704 cy,
1705 dw,
1706 dh
1707 ));
1708 }
1709 }
1710 // …and it must NOT be in the shadow (pure overlay).
1711 match rsnix::gop::shadow_pixel(cx + 2, cy) {
1712 Some(p) if p != white => report.push_str("cursor not in shadow: OK\n"),
1713 p => {
1714 ok = false;
1715 report.push_str(&alloc::format!("cursor in shadow: FAIL ({:?})\n", p));
1716 }
1717 }
1718
1719 term_println(&report);
1720 term_println(if ok {
1721 "gfx check: PASS"
1722 } else {
1723 "gfx check: FAIL"
1724 });
1725}
1726
1605// --------------------------------------------------------------------------- 1727// ---------------------------------------------------------------------------
1606// Multitasking: bg / jobs / sleep / kill / demo 1728// Multitasking: bg / jobs / sleep / kill / demo
1607// 1729//