#![no_std] //! Command shell - reads from PS/2 keyboard, writes to GOP + serial. //! //! One canonical grammar for every command (ADR 0002): //! //! ```text //! cmd args [USE src] [TIL marker] [filters…] [output kw dst] //! ``` //! //! - **Keywords** (uppercase-only, reserved operators): `WRITING`/`WRITE` //! overwrite a file, `TO` appends, `USE`/`USING` names an input source, //! `TIL`/`UNTIL` ends input at an exact full-line marker. //! - **Filters** are trailing flags applied left to right as stages over a //! line stream: `-h N` (first N lines, then kill the upstream), //! `-t N` (hold back a rolling window of the last N lines, emit only at //! end of stream), `-g PAT` (smart-case literal substring), `-p` (pause //! at each screenful). Each may repeat. //! - **Quoting** (`"..."` groups + literalizes, `\x` escapes one char) //! makes quoted/escaped text literal - never an operator, never split. //! - `>` and friends are gone; `>` is ordinary text. //! - ^D is EOF everywhere and never powers off; `poweroff`/`exit` are the //! only shutdown verbs. extern crate alloc; use alloc::collections::VecDeque; use alloc::string::{String, ToString}; use alloc::vec::Vec; use rsnix::tty::{self, LineInput}; use alloc::boxed::Box; use rsnix::{gop_print, gop_println}; /// Run the home-terminal shell (task 0). The home terminal is an /// ordinary WM window (id 0) owned by this task, so input flows through /// the same pumped queue as any windowed shell. pub fn run() -> ! { // Row 0 is the boot-marker status line (white-on-black, the last boot // stage drawn during init). Real hardware has no serial console, so // a freeze must leave that line visible - start the shell one row // below instead of overwriting it. gop_println!(); gop_println!( "rs-dos v{} - type 'help' for commands", env!("CARGO_PKG_VERSION") ); term_println(&alloc::format!( "========== rs-dos v{} ==========", env!("CARGO_PKG_VERSION") )); // A crash report from a previous run survives on the disk (the fault // handler writes crash.log on the mounted volume before halting; real // hardware has no serial console). Surface it so the user can report it. if let Some(h) = rsnix::fs::read_crash_log() { term_println("!!! PREVIOUS CRASH REPORT:"); let mut buf = [0u8; 256]; if let Ok(n) = rsnix::fs::read(h, &mut buf, 0) && n > 0 { let s = core::str::from_utf8(&buf[..n]).unwrap_or("?"); for line in s.split('\n') { if !line.is_empty() { term_println(line); } } } term_println("!!! end of crash report"); } let term = rsnix::vt::home_terminal(); loop { // Push any deferred scrolls so the prompt renders on a current // framebuffer (e.g. after a streaming command's batch of output). rsnix::vt::sync_scroll(); // Cheap hotplug probe: if any AHCI port's device presence changed // since the last scan, re-run discovery (mounts new volumes, // unmounts gone ones) before showing the prompt. if rsnix::ahci::poll_dirty() { report_rescan(rescan_all()); } // Print prompt term_print("> "); // Read one line (tty canonical mode over the pumped queue). match tty::read_line(term) { None => { // Window 0 is never closed; a Close message here means // shutdown. Park forever (the idle task takes over). loop { rsnix::sched::sleep_ms(1000); } } Some(LineInput::Interrupted) => continue, // ^C - fresh prompt Some(LineInput::Eof) => continue, // ^D - fresh prompt (no poweroff) Some(LineInput::Line(line)) => { let trimmed = line.trim(); if trimmed.is_empty() { continue; } execute(trimmed); } } } } /// Run a shell instance inside a windowed terminal. Same as `run()` but /// for a spawned F1 terminal (own task + window). /// Returns when the window is closed (Close message or ^D on empty line). pub fn run_windowed(term: &mut rsnix::vt::Terminal) { term_println(&alloc::format!("rs-dos v{}", env!("CARGO_PKG_VERSION"))); loop { rsnix::vt::sync_scroll(); term_print("> "); match tty::read_line(term) { None => return, // window closed Some(LineInput::Interrupted) => continue, Some(LineInput::Eof) => return, // ^D in windowed mode = close Some(LineInput::Line(line)) => { let trimmed = line.trim(); if trimmed.is_empty() { continue; } execute(trimmed); } } } } // --------------------------------------------------------------------------- // Diagnostics and command output go straight to terminal + serial, never // through the filter pipeline and never into a file sink. These helpers // write to the serial port directly - NOT via `serial_println!`, which // feeds the klog ring - so user-facing shell output (prompts, echoes, // command results) never lands in the boot log. // --------------------------------------------------------------------------- fn term_print(s: &str) { gop_print!("{}", s); rsnix::serial::write_str(s); } fn term_println(s: &str) { gop_print!("{}", s); gop_println!(); // One locked call (line + CRLF) so a tick can't split the pair and // let a background task's serial line interleave mid-line. rsnix::serial::write_line(s); } fn term_err(msg: &str) { term_println(msg); } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /// One lexical token: its text plus whether any quoting/escaping was used /// (a token containing quoted/escaped text is never an operator or flag). struct Token { text: String, literal: bool, } /// Split a command line into words, handling `"..."` groups and `\x` /// escapes. Quotes are delimiters, not content; escapes prevent splitting /// and literalize one character (`\"` → `"`, `\ ` → space). fn tokenize(s: &str) -> Result, &'static str> { let mut toks: Vec = Vec::new(); let mut cur = String::new(); let mut cur_literal = false; let mut in_quote = false; let mut chars = s.chars(); while let Some(c) = chars.next() { match c { '"' => { cur_literal = true; in_quote = !in_quote; } '\\' => { cur_literal = true; match chars.next() { Some(e) => cur.push(e), None => cur.push('\\'), // trailing backslash: literal } } c if c.is_whitespace() && !in_quote => { if !cur.is_empty() { toks.push(Token { text: core::mem::take(&mut cur), literal: cur_literal, }); cur_literal = false; } } c => cur.push(c), } } if in_quote { return Err("unterminated quote"); } if !cur.is_empty() { toks.push(Token { text: cur, literal: cur_literal, }); } Ok(toks) } fn is_keyword(s: &str) -> bool { matches!( s, "WRITING" | "WRITE" | "TO" | "USE" | "USING" | "TIL" | "UNTIL" ) } /// How the command's input is fed. Only `cat`/`hexdump`, and only when /// path-less, may carry an input keyword. #[derive(Clone)] enum Input { None, /// `USE f` - read from a file. Use(String), /// `TIL m` - read from the keyboard until an exact line equals `m`. Til(String), /// `USE f TIL m` - read `f` until an exact line equals `m`. UseTil(String, String), } impl Input { fn present(&self) -> bool { !matches!(self, Input::None) } } /// A post-filter stage. Applied left to right over the line stream. #[derive(Clone)] enum Filter { Head(usize), Tail(usize), Grep(String), Pause, } /// Output destination. #[derive(Clone)] enum Sink { Write(String), Append(String), } struct Parsed { cmd: String, args: Vec, input: Input, filters: Vec, output: Option, } /// Parse one command line into the canonical grammar. Errors are the /// syntax-error message to show the user. fn parse(s: &str) -> Result { let toks = tokenize(s).map_err(|e| e.to_string())?; if toks.is_empty() { return Err("empty command".into()); } let cmd = toks[0].text.clone(); let mut i = 1; let mut args: Vec = Vec::new(); let mut filters: Vec = Vec::new(); let mut output: Option = None; let mut trailing = false; // a filter flag or output keyword has been seen let mut src: Option = None; let mut til: Option = None; let mut til_seen = false; while i < toks.len() { let t = &toks[i]; let text = t.text.as_str(); if !t.literal { match text { "USE" | "USING" => { if output.is_some() { return Err("USE must come before the output keyword".into()); } if trailing { return Err("USE must come before the filters".into()); } if src.is_some() || til_seen { return Err("duplicate input keyword".into()); } i += 1; let path = toks.get(i).ok_or("USE needs a source path")?; if !path.literal && is_keyword(&path.text) { return Err("USE needs a source path".into()); } src = Some(path.text.clone()); i += 1; continue; } "TIL" | "UNTIL" => { if output.is_some() { return Err("TIL must come before the output keyword".into()); } if trailing { return Err("TIL must come before the filters".into()); } if til_seen { return Err("duplicate TIL".into()); } i += 1; let m = toks.get(i).ok_or("TIL needs a marker")?; if !m.literal && is_keyword(&m.text) { return Err("TIL needs a marker".into()); } til = Some(m.text.clone()); til_seen = true; i += 1; continue; } "WRITING" | "WRITE" | "TO" => { if output.is_some() { return Err("duplicate output keyword".into()); } i += 1; let dst = toks.get(i).ok_or("output keyword needs a destination")?; if !dst.literal && is_keyword(&dst.text) { return Err("output keyword needs a destination".into()); } output = Some(if text == "TO" { Sink::Append(dst.text.clone()) } else { Sink::Write(dst.text.clone()) }); trailing = true; i += 1; continue; } "-h" | "-t" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; i += 1; let n = toks.get(i).ok_or("flag needs a count")?; let n: usize = n.text.parse().map_err(|_| "count must be a number ≥ 1")?; if n < 1 { return Err("count must be ≥ 1".into()); } filters.push(if text == "-h" { Filter::Head(n) } else { Filter::Tail(n) }); i += 1; continue; } "-g" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; i += 1; let pat = toks.get(i).ok_or("-g needs a pattern")?; filters.push(Filter::Grep(pat.text.clone())); i += 1; continue; } "-p" => { if output.is_some() { return Err("filters must come before the output keyword".into()); } trailing = true; filters.push(Filter::Pause); i += 1; continue; } _ => {} } } // Ordinary token. Once the trailing region has started (a filter // flag or the output keyword), stray arguments are syntax errors. if trailing { return Err(alloc::format!("unexpected argument '{}'", text)); } args.push(text.to_string()); i += 1; } let input = match (src, til) { (Some(s), Some(t)) => Input::UseTil(s, t), (Some(s), None) => Input::Use(s), (None, Some(t)) => Input::Til(t), (None, None) => Input::None, }; Ok(Parsed { cmd, args, input, filters, output, }) } // --------------------------------------------------------------------------- // Filter pipeline + output sink // --------------------------------------------------------------------------- /// Per-stage mutable state. enum StageState { /// Lines still allowed through before the upstream is killed. Head(usize), /// Rolling window of the last N lines. Tail(VecDeque), Grep, Pause { shown: usize, screen: usize, cols: usize, }, } /// Where the filtered line stream ends up. enum OutSink { Term, File { path: String, h: Option, pos: u64, overwrite: bool, }, } /// Smart-case literal substring match: case-insensitive unless the pattern /// contains an uppercase letter. fn smart_match(pat: &str, line: &str) -> bool { if pat.chars().any(|c| c.is_uppercase()) { line.contains(pat) } else { let lp = pat.to_ascii_lowercase(); let ll = line.to_ascii_lowercase(); ll.contains(&lp) } } /// Pager pause: terminal only, waits for a keypress; ^C aborts the stream. fn pager_wait() -> bool { gop_print!("\n- more -"); match tty::read_byte() { 0x03 => { // ^C - abort the stream (stream-abort role). gop_println!(); false } _ => { // Any key continues; swallow queued tail bytes (e.g. the // rest of an arrow-key escape sequence). while tty::try_read_byte().is_some() {} true } } } /// The filter chain plus the output sink. Producers push one line at a /// time; `line()` returns false when the upstream must stop (head limit or /// pager ^C). At end of stream call `end()` to flush held-back tails. struct Pipeline { stages: Vec<(Filter, StageState)>, sink: OutSink, } impl Pipeline { fn new(filters: &[Filter], output: &Option) -> Self { let file_sink = output.is_some(); let mut stages: Vec<(Filter, StageState)> = Vec::new(); for f in filters { // With a file sink the pager is a no-op. if file_sink && matches!(f, Filter::Pause) { continue; } let st = match f { Filter::Head(n) => StageState::Head(*n), Filter::Tail(n) => StageState::Tail(VecDeque::with_capacity(*n)), Filter::Grep(_) => StageState::Grep, Filter::Pause => StageState::Pause { shown: 0, screen: rsnix::vt::size().0.saturating_sub(1).max(1), // Rows a screenful of *wrapped* output occupies: a // line wider than the terminal takes more than one // row, so the pager counts rows, not lines. cols: rsnix::vt::size().1.max(1), }, }; stages.push((f.clone(), st)); } let sink = match output { Some(Sink::Write(p)) => OutSink::File { path: p.clone(), h: None, pos: 0, overwrite: true, }, Some(Sink::Append(p)) => OutSink::File { path: p.clone(), h: None, pos: 0, overwrite: false, }, None => OutSink::Term, }; Pipeline { stages, sink } } /// Push one line through the stages. Returns false when the producer /// must stop (head killed the stream, or ^C at the pager). fn line(&mut self, l: &str) -> bool { self.push_through(0, l) } /// Run one line through the stages starting at `start`, delivering to /// the sink. `start` lets `finish()` replay a drained tail window /// through only the stages after it. fn push_through(&mut self, start: usize, l: &str) -> bool { let mut cur = alloc::vec![l.to_string()]; for (i, (f, st)) in self.stages.iter_mut().enumerate() { if i < start { continue; } let mut out = Vec::new(); match f { Filter::Head(_) => { let StageState::Head(rem) = st else { unreachable!("stage state does not match filter") }; if *rem == 0 { // Head limit reached: kill the upstream. The line // that would have overflowed is dropped. return false; } for l in cur.drain(..) { *rem -= 1; out.push(l); } } Filter::Tail(n) => { let StageState::Tail(buf) = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { if buf.len() == *n { buf.pop_front(); } buf.push_back(l); } // Held back - nothing reaches the downstream yet. return true; } Filter::Grep(pat) => { let StageState::Grep = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { if smart_match(pat, &l) { out.push(l); } } } Filter::Pause => { let StageState::Pause { shown, screen, cols, } = st else { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { // Rows this line occupies after terminal wrapping // (at least 1; an empty line is one row). let rows = l.len().div_ceil(*cols); if *shown > 0 && *shown % *screen == 0 && !pager_wait() { return false; } *shown += rows; out.push(l); } } } cur = out; } for l in &cur { self.emit(l); } true } /// Flush held-back tail windows through the remaining stages. Called /// at end of stream: EOF, ^C, or after a head kill. fn finish(&mut self) { loop { let mut any = false; for i in 0..self.stages.len() { let drained: Vec = match &mut self.stages[i] { (Filter::Tail(_), StageState::Tail(buf)) if !buf.is_empty() => { buf.drain(..).collect() } _ => continue, }; any = true; for l in drained { self.push_through(i + 1, &l); } } if !any { break; } } } /// End of stream: flush tails, done. File writes are synchronous. fn end(&mut self) { self.finish(); } fn emit(&mut self, l: &str) { match &self.sink { OutSink::Term => { term_println(l); } OutSink::File { .. } => {} } if let OutSink::File { h: None, .. } = &self.sink { self.open_sink(); } if let OutSink::File { h: Some(h), pos, .. } = &mut self.sink { let mut bytes = l.as_bytes().to_vec(); bytes.push(b'\n'); if let Ok(n) = rsnix::fs::write(*h, &bytes, *pos) { *pos += n as u64; } } } /// Lazily open the file sink on first output (a command that emits /// nothing touches no file). WRITING truncates and writes from offset /// 0 (the node's cached size is stale after truncation); TO appends at /// the file's current size. fn open_sink(&mut self) { let (path, overwrite) = match &self.sink { OutSink::File { path, overwrite, .. } => (path.clone(), *overwrite), _ => return, }; match rsnix::fs::ensure_file(&path) { Ok(h) => { let pos = if overwrite { let _ = rsnix::fs::set_len(h, 0); 0 } else { rsnix::fs::size(h) }; self.sink = OutSink::File { path, h: Some(h), pos, overwrite, }; } Err(()) => { term_err(&alloc::format!("cannot write '{}'", path)); self.sink = OutSink::Term; } } } } /// Emit every line of a pre-built multi-line string (interior blank lines /// preserved; a trailing newline does not produce an extra empty line). fn emit_string(pipe: &mut Pipeline, s: &str) { let mut rest = s; loop { match rest.find('\n') { Some(idx) => { if !pipe.line(&rest[..idx]) { return; } rest = &rest[idx + 1..]; } None => { if !rest.is_empty() { let _ = pipe.line(rest); } return; } } } } // --------------------------------------------------------------------------- // Execution // --------------------------------------------------------------------------- /// Commands that may consume an input stream (`USE`/`TIL`). fn accepts_input(cmd: &str) -> bool { // Built-in stream consumers, plus any external binary in /bin/cli — // externals get injected streams by construction (ADR 0006), so the // keyword is never ambiguous for them. if matches!(cmd, "cat" | "hexdump") { return true; } rsnix::exec::resolve("cli", cmd).is_some() } fn execute(s: &str) { let parsed = match parse(s) { Ok(p) => p, Err(e) => { term_err(&e); return; } }; let cmd = parsed.cmd.as_str(); if parsed.input.present() { if !accepts_input(cmd) { term_err(&alloc::format!("{}: does not accept input", cmd)); return; } // `cat TIL ` is sugar for `cat USE TIL // `: the paths are the input source, truncated at the // marker. An explicit source keyword (USE / USE..TIL) alongside // path arguments is genuinely two input sources. if !parsed.args.is_empty() && !matches!(parsed.input, Input::Til(_)) { term_err(&alloc::format!( "{}: input source keyword with path arguments is ambiguous - \ the paths are the input; drop the keyword, or to truncate them at a marker \ write `{} TIL `", cmd, cmd )); return; } } // Resolution rule (ADR 0006): \bin\\ first, else the // built-in. The frozen core (help/clear/echo/poweroff/exit/halt/ // rescan) is exempt — it exists to fix the system and never migrates. if !matches!( cmd, "help" | "clear" | "echo" | "halt" | "poweroff" | "exit" | "rescan" ) && exec_external(&parsed) { return; } match cmd { "help" => cmd_help(&parsed), "echo" => cmd_echo(&parsed), "clear" => cmd_clear(), "halt" => cmd_halt(), "poweroff" | "exit" => cmd_poweroff(), "hello" => cmd_hello(&parsed), "info" => cmd_info(&parsed), "mem" => cmd_mem(&parsed), "heapdbg" => cmd_heapdbg(&parsed), "mmap" => cmd_mmap(&parsed), "stress" => cmd_stress(&parsed), "date" => cmd_date(&parsed), "pci" => cmd_pci(&parsed), "usb" => cmd_usb(&parsed), "rescan" => cmd_rescan(&parsed), "mouse" => cmd_mouse(&parsed), "cursor" => cmd_cursor(&parsed), "gfx" => cmd_gfx(&parsed), "ls" => cmd_ls(&parsed), "cat" => cmd_cat(&parsed), "klog" => cmd_klog(&parsed), "hexdump" => cmd_hexdump(&parsed), "mkdir" => cmd_mkdir(&parsed), "touch" => cmd_touch(&parsed), "rm" => cmd_rm(&parsed), "edit" => cmd_edit(&parsed), "draw" => cmd_draw(&parsed), "term" => cmd_term(&parsed), "windows" => cmd_windows(&parsed), "restore" => cmd_restore(&parsed), "bg" => cmd_bg(&parsed), "jobs" => cmd_jobs(&parsed), "msgrecv" => cmd_msgrecv(&parsed), "send" => cmd_send(&parsed), "sleep" => cmd_sleep(&parsed), "kill" => cmd_kill(&parsed), "demo" => cmd_demo(&parsed), "fault" => cmd_fault(), "corrupt" => cmd_corrupt(), "crashlog" => cmd_crashlog(), "stall" => cmd_stall(&parsed), _ => term_err(&alloc::format!("unknown command: {}", cmd)), } } // --------------------------------------------------------------------------- // External binaries (ADR 0006 Phase 3) // // Resolution: \bin\cli\ on each mounted volume; else built-in. // // Stream injection: the Exports callbacks run in the COMMAND task but // touch shell-owned state. This is safe by construction — the invoking // shell blocks in exec_external until the command completes, so nothing // else touches the Pipeline or the channel. One invocation at a time. // // put_line → Pipeline::line directly (output side needs no queue). // get_line ← a bounded per-invocation ring; while the command runs, // the blocked shell "pumps": drains its key queue, assembles // canonical lines, pushes them into the ring. ^C marks the // channel aborted instead of killing a stream consumer. // --------------------------------------------------------------------------- static mut LAST_EXIT_CODE: i32 = 0; /// The pipeline of the running external command (None when idle). static mut ACTIVE_PIPELINE: Option = None; /// Last exit code of an external command ($? equivalent for scripts). pub fn last_exit_code() -> i32 { unsafe { core::ptr::addr_of!(LAST_EXIT_CODE).read() } } /// Bounded line channel between the pumping shell and the command task. struct LineChannel { lines: Vec, len: usize, cap: usize, /// ^C seen: get_line returns Abort once drained. aborted: bool, /// Source exhausted (EOF / marker hit). eof: bool, /// Interactive TIL: exact line that closes the input (pump checks it). marker: Option, } impl LineChannel { const CAP: usize = 16; fn new() -> Self { LineChannel { lines: Vec::with_capacity(Self::CAP), len: 0, cap: Self::CAP, aborted: false, eof: false, marker: None, } } /// Shell side: push one assembled line. Returns false when full /// (the pump waits and retries). fn push(&mut self, s: &str) -> bool { if self.len >= self.cap { return false; } self.lines.push(String::from(s)); self.len += 1; true } /// Command side: pop one line. fn pop(&mut self) -> Option { if self.len == 0 { return None; } let s = self.lines.remove(0); self.len -= 1; Some(s) } } /// The current invocation. Only touched by the shell (pump + setup) and /// by the ABI callbacks (command task) — never concurrently, see above. static mut INVOCATION: Option = None; /// Set when the input source is interactive (tty): the pump assembles /// canonical lines from keys. File/marker sources are pre-drained into /// the channel before spawn instead. fn with_invocation(f: impl FnOnce(&mut LineChannel) -> R) -> R { let inv = unsafe { match &mut *core::ptr::addr_of_mut!(INVOCATION) { Some(ch) => ch, None => panic!("exec: no active invocation"), } }; f(inv) } /// ABI callback: print one line through the filter pipeline / sink. fn abi_put_line(line: rsapi::Line) -> bool { let pipe = unsafe { match &mut *core::ptr::addr_of_mut!(ACTIVE_PIPELINE) { Some(p) => p, None => panic!("exec: no active pipeline"), } }; pipe.line(line.as_str()) } /// ABI callback: read one input line from the invocation channel. fn abi_get_line(out: &mut rsapi::Line) -> rsapi::StreamResult { loop { let (line, status) = with_invocation(|ch| match ch.pop() { Some(l) => (Some(l), rsapi::StreamResult::Line), None if ch.aborted => (None, rsapi::StreamResult::Abort), None if ch.eof => (None, rsapi::StreamResult::Eof), None => (None, rsapi::StreamResult::Line), // empty but open: pump }); match status { rsapi::StreamResult::Abort => return rsapi::StreamResult::Abort, rsapi::StreamResult::Eof => return rsapi::StreamResult::Eof, rsapi::StreamResult::Line => { if let Some(l) = line { out.ptr = l.as_ptr(); out.len = l.len(); // Leak the String into the command's lifetime: lines are // one-shot and the task dies soon; freeing across tasks // is not worth the bookkeeping. core::mem::forget(l); return rsapi::StreamResult::Line; } // Channel empty: yield so the shell pump can refill. rsnix::sched::yield_now(); } } } } /// ABI callback: kernel log. fn abi_log_line(line: rsapi::Line) { rsnix::klog::append(line.as_str(), 0); } /// ABI callback: exit with a code. fn abi_exit(code: i32) -> ! { unsafe { LAST_EXIT_CODE = code; } // Kill the calling task; exec_external sees the abort notification. rsnix::sched::abort_current("exit") } /// Directory iteration cache: one slot per simultaneous fs_dir_next. struct DirIter { entries: Vec<(String, rsnix::fs::FileType, u64)>, pos: usize, } static mut DIR_CACHE: [Option; 16] = [const { None }; 16]; fn cache_key(h: i32) -> usize { ((h as usize) & 0x7FFFFFFF) % 16 } /// ABI: resolve a path to a handle (-1 on error). fn abi_fs_resolve(path: rsapi::Line) -> rsapi::FsHandle { match rsnix::fs::resolve(path.as_str()) { Some(h) => h as i32, None => -1, } } /// ABI: iterate directory children one at a time. fn abi_fs_dir_next(handle: rsapi::FsHandle, out: &mut rsapi::DirEntry) -> bool { let key = cache_key(handle); unsafe { if DIR_CACHE[key].is_none() { let h = handle as usize; let kids = rsnix::fs::children(h); DIR_CACHE[key] = Some(DirIter { entries: kids, pos: 0 }); } let iter = DIR_CACHE[key].as_mut().unwrap(); if iter.pos < iter.entries.len() { let (ref name, kind, size) = iter.entries[iter.pos]; out.name = rsapi::Line { ptr: name.as_ptr(), len: name.len() }; out.is_dir = kind == rsnix::fs::FileType::Dir; out.size = size; iter.pos += 1; true } else { DIR_CACHE[key] = None; false } } } /// ABI: create a file or directory at path. fn abi_fs_create(path: rsapi::Line, is_dir: bool) -> i32 { let p = path.as_str(); if is_dir { match rsnix::fs::mkdir(p) { Ok(()) => 0, Err(()) => -1, } } else { match rsnix::fs::ensure_file(p) { Ok(_) => 0, Err(()) => -1, } } } /// ABI: remove a file or empty directory. fn abi_fs_remove(path: rsapi::Line) -> i32 { match rsnix::fs::remove_path(path.as_str()) { Ok(()) => 0, Err(()) => -1, } } /// ABI: read bytes from a handle at a position. fn abi_fs_read(handle: rsapi::FsHandle, buf: *mut u8, pos: u64, len: u64) -> i64 { let h = handle as usize; let mut tmp = [0u8; 512]; let mut total = 0i64; let mut off = pos; let mut rem = len as usize; while rem > 0 { let chunk = rem.min(512); match rsnix::fs::read(h, &mut tmp[..chunk], off) { Ok(0) | Err(_) => break, Ok(n) => { unsafe { core::ptr::copy_nonoverlapping(tmp.as_ptr(), buf.add(total as usize), n); } total += n as i64; off += n as u64; rem -= n; } } } total } /// ABI: write bytes to a handle at a position. fn abi_fs_write(handle: rsapi::FsHandle, buf: *const u8, pos: u64, len: u64) -> i64 { let h = handle as usize; if len == 0 { return 0; } let slice = unsafe { core::slice::from_raw_parts(buf, len as usize) }; match rsnix::fs::write(h, slice, pos) { Ok(n) => n as i64, Err(()) => -1, } } /// ABI: get file size. fn abi_fs_size(handle: rsapi::FsHandle) -> u64 { rsnix::fs::size(handle as usize) } /// ABI: close handle (currently no-op). fn abi_fs_close(_handle: rsapi::FsHandle) {} /// ABI: current real-time clock. fn abi_rtc_now(out: &mut rsapi::RtcTime) { let t = rsnix::rtc::now(); out.year = t.year; out.month = t.month; out.day = t.day; out.hour = t.hour; out.minute = t.minute; out.second = t.second; } /// ABI: memory statistics. fn abi_mem_stats(out: &mut rsapi::MemStats) { let s = rsnix::memory::heap_stats(); out.heap_total = s.heap_size as u64; out.heap_free = s.free_bytes as u64; let ps = rsnix::memory::page_stats(); out.pages_total = ps.span_pages as u64; out.pages_free = ps.free_pages as u64; } /// ABI: enumerate PCI devices. fn abi_pci_probe() -> u32 { rsnix::pci::probe().len() as u32 } /// ABI: get one PCI device by index. fn abi_pci_device(index: u32, out: &mut rsapi::PciDevice) -> bool { let devs = rsnix::pci::probe(); match devs.get(index as usize) { Some(d) => { out.bus = d.bus; out.device = d.device; out.function = d.func; out.class = d.class; out.subclass = d.subclass; out.vendor = d.vendor; out.device_id = d.device_id; true } None => false, } } /// ABI: dump kernel log ring into buffer. fn abi_klog_dump(buf: *mut u8, cap: u64) -> u64 { let s = rsnix::klog::dump(); let bytes = s.as_bytes(); let n = (bytes.len() as u64).min(cap) as usize; unsafe { core::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, n); } n as u64 } /// ABI: kernel version info. fn abi_kernel_version(out: &mut rsapi::KernelVersion) { let name = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION")); let mut it = env!("CARGO_PKG_VERSION").split('.'); let major: u16 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); let minor: u16 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); let patch: u16 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); out.name = rsapi::Line { ptr: name.as_ptr(), len: name.len() }; out.major = major; out.minor = minor; out.patch = patch; } /// ABI: create a window via the window manager. fn abi_wm_create(title: rsapi::Line, w: u32, h: u32) -> i32 { let s = title.as_str(); match rsnix::wm::create_window(s, w as usize, h as usize) { Some(id) => id as i32, None => -1, } } /// ABI: get window pixel buffer info. fn abi_wm_buffer(id: i32, out: &mut rsapi::WindowInfo) -> bool { match rsnix::wm::buffer(id as usize) { Some((ptr, w, h)) => { out.ptr = ptr; out.w = w as u32; out.h = h as u32; true } None => false, } } /// ABI: composite window to screen. fn abi_wm_flush(id: i32) { rsnix::wm::flush(id as usize); } /// ABI: destroy a window. fn abi_wm_destroy(id: i32) { rsnix::wm::destroy(id as usize); } /// ABI: non-blocking receive of one WM event. fn abi_wm_recv(out: &mut rsapi::WmEvent) -> bool { use rsnix::ps2::KeyEvent; match rsnix::sched::recv() { None => false, Some(msg) => { match msg { rsnix::sched::TaskMsg::Key(k) => { out.kind = 0; match k { KeyEvent::Char(c) => { out.key_kind = rsapi::WmKeyKind::Char; out.key_char = c as u8; } KeyEvent::Enter => { out.key_kind = rsapi::WmKeyKind::Enter; } KeyEvent::Backspace => { out.key_kind = rsapi::WmKeyKind::Backspace; } KeyEvent::Tab => { out.key_kind = rsapi::WmKeyKind::Tab; } KeyEvent::Escape => { out.key_kind = rsapi::WmKeyKind::Escape; } KeyEvent::Ctrl('c') => { out.key_kind = rsapi::WmKeyKind::CtrlC; } KeyEvent::ArrowUp => { out.key_kind = rsapi::WmKeyKind::ArrowUp; } KeyEvent::ArrowDown => { out.key_kind = rsapi::WmKeyKind::ArrowDown; } KeyEvent::ArrowLeft => { out.key_kind = rsapi::WmKeyKind::ArrowLeft; } KeyEvent::ArrowRight => { out.key_kind = rsapi::WmKeyKind::ArrowRight; } KeyEvent::Home => { out.key_kind = rsapi::WmKeyKind::Home; } KeyEvent::End => { out.key_kind = rsapi::WmKeyKind::End; } KeyEvent::PageUp => { out.key_kind = rsapi::WmKeyKind::PageUp; } KeyEvent::PageDown => { out.key_kind = rsapi::WmKeyKind::PageDown; } KeyEvent::Delete => { out.key_kind = rsapi::WmKeyKind::Delete; } KeyEvent::F(1) => { out.key_kind = rsapi::WmKeyKind::F1; } KeyEvent::F(2) => { out.key_kind = rsapi::WmKeyKind::F2; } KeyEvent::F(3) => { out.key_kind = rsapi::WmKeyKind::F3; } KeyEvent::F(4) => { out.key_kind = rsapi::WmKeyKind::F4; } _ => { out.key_kind = rsapi::WmKeyKind::Unknown; } } out.key_char = 0; true } rsnix::sched::TaskMsg::AppMouse(m) => { out.kind = 1; out.mouse.x = m.x; out.mouse.y = m.y; out.mouse.buttons = m.buttons; true } rsnix::sched::TaskMsg::Focus(f) => { out.kind = 2; out.focused = f; true } rsnix::sched::TaskMsg::Close => { out.kind = 3; true } _ => false, } } } } /// Construct the Exports table for this invocation. v2 fields are /// populated at runtime (binary callbacks → shell → kernel). fn build_exports() -> rsapi::Exports { rsapi::Exports { magic: rsapi::EXPORTS_MAGIC, version: rsapi::ABI_VERSION, put_line: abi_put_line, get_line: abi_get_line, log_line: abi_log_line, exit: abi_exit, fs_resolve: abi_fs_resolve, fs_dir_next: abi_fs_dir_next, fs_create: abi_fs_create, fs_remove: abi_fs_remove, fs_read: abi_fs_read, fs_write: abi_fs_write, fs_size: abi_fs_size, fs_close: abi_fs_close, rtc_now: abi_rtc_now, mem_stats: abi_mem_stats, pci_probe: abi_pci_probe, pci_device: abi_pci_device, klog_dump: abi_klog_dump, kernel_version: abi_kernel_version, wm_create: abi_wm_create, wm_buffer: abi_wm_buffer, wm_flush: abi_wm_flush, wm_destroy: abi_wm_destroy, wm_recv: abi_wm_recv, } } /// Run `cmd` as an external PE32+ binary from /bin/cli/. Returns false /// when no external binary resolved (the caller falls back to built-ins). fn exec_external(p: &Parsed) -> bool { let Some(h) = rsnix::exec::resolve("cli", &p.cmd) else { return false; }; let bytes = match rsnix::exec::read_file(h) { Ok(b) => b, Err(_) => { term_err(&alloc::format!("{}: read failed", p.cmd)); return true; } }; let image = match rsnix::load::load_image(&bytes) { Ok(img) => img, Err(e) => { term_err(&alloc::format!("{}: cannot load ({:?})", p.cmd, e)); return true; } }; // Grammar stays shell-side (ADR 0006): the binary gets clean argv and // injected streams. Build its pipeline exactly like a built-in's. let pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] // Input channel: pre-drain file sources; tty sources are pumped. let mut channel = LineChannel::new(); match &p.input { Input::None => {} Input::Use(path) => { drain_file_into(&mut channel, path, None); channel.eof = true; } Input::UseTil(path, marker) => { drain_file_into(&mut channel, path, Some(marker)); channel.eof = true; } Input::Til(marker) => { if p.args.is_empty() { // Interactive: read from tty until marker — the pump // checks each submitted line against it. channel.marker = Some(marker.clone()); } else { for path in &p.args { drain_file_into(&mut channel, path, Some(marker)); } channel.eof = true; } } } let mut argv: Vec = Vec::with_capacity(p.args.len() + 1); argv.push(p.cmd.clone()); argv.extend(p.args.iter().cloned()); let name = alloc::format!("{}", p.cmd); // Publish the invocation state before spawn (the command may call // put_line immediately). unsafe { *core::ptr::addr_of_mut!(ACTIVE_PIPELINE) = Some(pipe); *core::ptr::addr_of_mut!(INVOCATION) = Some(channel); } let mut cmd_done = false; let mut exit_code: i64 = 0; let exports = Box::leak(Box::new(build_exports())); match rsnix::exec::spawn(image, exports, &argv, &name) { Ok(_id) => { // Pumping mode: assemble canonical lines from our queue into // the channel while the command runs. The WM pump delivers // keys to us only while our window is focused. loop { match rsnix::sched::recv_blocking() { rsnix::sched::TaskMsg::User(code) => { exit_code = if code == u64::MAX { -1 } else { code as i64 }; cmd_done = true; } rsnix::sched::TaskMsg::Key(rsnix::ps2::KeyEvent::Ctrl('c')) => { // ^C: mark the channel aborted; stream consumers stop // at their next get_line/put_line-false. Compute- // bound commands stay blind to it (accepted policy). with_invocation(|ch| ch.aborted = true); } rsnix::sched::TaskMsg::Key(ev) => { // Canonical assembly for the interactive source: // mirror tty::read_line's key handling, minus echo // (the terminal already echoed it? no — the shell // must echo here since nothing else does). pump_key(ev); } _ => {} } if cmd_done { break; } } } Err(rsnix::exec::ExecError::Busy) => { term_err(&alloc::format!("{}: task table full", p.cmd)) } Err(_) => term_err(&alloc::format!("{}: cannot start", p.cmd)), } unsafe { let pipe_ptr = core::ptr::addr_of_mut!(ACTIVE_PIPELINE); if let Some(mut pipe) = (*pipe_ptr).take() { pipe.end(); } *core::ptr::addr_of_mut!(INVOCATION) = None; LAST_EXIT_CODE = exit_code as i32; } true } /// One key of canonical assembly during pumping. Lines land in the /// channel; ^D closes it; Enter submits the line. Echo goes to the /// terminal (this is the command's stdin). fn pump_key(ev: rsnix::ps2::KeyEvent) { use rsnix::ps2::KeyEvent; // Static assembly buffer: only touched between keys by the shell. static mut PUMP_BUF: Option = None; let buf = unsafe { let p = core::ptr::addr_of_mut!(PUMP_BUF); if (*p).is_none() { *p = Some(String::new()); } (*p).as_mut().unwrap() }; match ev { KeyEvent::Enter => { gop_println!(); let line = core::mem::take(buf); with_invocation(|ch| { // Interactive TIL: the submitted line itself is checked // against the marker (it is consumed, not delivered). if let Some(m) = &ch.marker && line.as_str() == m { ch.eof = true; } else { ch.push(line.as_str()); } }); } KeyEvent::Ctrl('c') => { buf.clear(); with_invocation(|ch| ch.aborted = true); } KeyEvent::Ctrl('d') => { if buf.is_empty() { with_invocation(|ch| ch.eof = true); } } KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => { buf.push(c); gop_print!("{}", c); } KeyEvent::Backspace => { if buf.pop().is_some() { gop_print!("\x08"); } } _ => {} } } /// Pre-drain a file into the channel; `marker` stops before that line /// (USE TIL sugar). The channel is left open — the caller sets /// `eof` when this is the whole source. fn drain_file_into(ch: &mut LineChannel, path: &str, marker: Option<&str>) { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("{}: no such file", path)); ch.eof = true; return; } }; drain_handle(ch, h, marker); } /// Shared drain: read `h` in chunks, push complete lines; stop at marker. fn drain_handle(ch: &mut LineChannel, h: usize, marker: Option<&str>) { let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); 'outer: loop { match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n').to_string(); if let Some(m) = marker && line == m { break 'outer; } while !ch.push(&line) { rsnix::sched::yield_now(); } } } } } if marker.is_none() && !pending.is_empty() { while !ch.push(&pending) { rsnix::sched::yield_now(); } } } // --------------------------------------------------------------------------- // Built-in commands // --------------------------------------------------------------------------- fn cmd_help(_p: &Parsed) { let mut pipe = Pipeline::new(&_p.filters, &_p.output); let text = "available commands: help - show this message echo - echo arguments clear - clear screen hello - print 'World!' info - show system info mem - show memory info mmap - dump the UEFI memory map stress - stress-test heap/page allocators date - show current date/time (RTC) pci - list PCI devices rescan - re-scan for hotplugged/removed disks (also runs automatically) cursor - report the on-screen mouse cursor (x, y, buttons) mouse - watch the cursor live (x/y/buttons); ^C stops mouse - route mouse events to task 's queue (cursor still tracks) gfx - demo: draw a status bar + RGB boxes (bottom strip) gfx check - pixel self-test: shadow, display push, cursor overlay bg - spawn a background task (spinner, ticker, counter, boom, overflow) msgrecv - spawn a task that blocks on its message queue and logs every message to serial (ids from `jobs`) send [args] - post a message to a task's queue: user · timer · mouse (1=L 2=R 4=M) key (enter esc tab space bksp up down left right home end pgup pgdn del ctrl-c f1..f12, or a single char) jobs - list tasks (id, name, state, ticks, q=pending messages) sleep - block the shell for ms; background tasks keep running kill - stop a background task (id from jobs) demo - spawn spinner + ticker + counter ls - list a directory cat - print files (cat USE a TO b copies a→b; cat TIL END is a heredoc) klog - kernel boot log (cat /dev/klog; WRITING persists it) hexdump - hex-dump a file or input stream mkdir - create a directory touch - create an empty file rm - remove a file or empty dir edit - full-screen editor (^S save, ^X exit, ^W search) term - spawn a windowed terminal (F1 also works) halt - halt the system (hlt loop) poweroff - shut down via ACPI (ResetSystem) exit - alias for poweroff redirection keywords (uppercase, after args): USE/USING read command input from a file (cat/hexdump only) TIL/UNTIL read input until an exact marker line (heredoc) cat TIL reads the path to the marker WRITING/WRITE write output to a file (overwrite) TO append output to a file order: USE/TIL first, then the -flags, then WRITING/TO last a file sink replaces the screen; > >> < << are gone (> is plain text) post-filter flags (trailing, applied left to right): -h N first N lines (kills the command after N) -t N last N lines (shows nothing until the stream ends; ^C flushes) -g PAT smart-case literal substring match (repeatable) -p pause at each screenful (more-style; ^C aborts) quoting: \"...\" groups text; \\x escapes one char. Quoted text is never an operator: echo \"WRITING\" prints WRITING. ^D = EOF; ^C cancels a line or aborts a stream. poweroff/exit shut down."; emit_string(&mut pipe, text); pipe.end(); } fn cmd_echo(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let line = p.args.join(" "); let _ = pipe.line(&line); pipe.end(); } fn cmd_hello(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let _ = pipe.line("World!"); pipe.end(); } /// Deliberately fault on an unmapped address - test hook that verifies /// the fault-dump path (screen + serial) halts cleanly instead of /// triple-faulting. The system stops; the log must show the dump. /// Canonical-but-unmapped address (below 2^47, above any RAM/MMIO): the /// page walk terminates at a not-present entry → #PF with CR2 = address. fn cmd_fault() { term_println("fault: writing to 0x0000_4000_0000_0000..."); unsafe { core::ptr::write_volatile(0x0000_4000_0000_0000usize as *mut u32, 0x1234u32); } } /// Deliberately clobber a live heap block's back-pointer, then free it - /// test hook that proves the allocator's corruption guard fires with a /// readable report (instead of a #GP inside `__rust_dealloc`). The /// system stops; the log must show `!!! heap corruption in dealloc`. fn cmd_corrupt() { term_println("corrupt: clobbering a live block's back-pointer..."); let s = String::from("hello heap guard"); let ptr = s.as_ptr() as usize; unsafe { *((ptr - 8) as *mut usize) = 0xdeed_beef_deed_beefusize; } drop(s); term_println("corrupt: guard did NOT fire (unexpected)"); } /// Debug hook: run the crash-log FAT write from the *shell* context (not /// the fault ISR) to compare behaviour. fn cmd_crashlog() { let lines = ["!!! test crashlog write", "second line", "third line"]; rsnix::fs::write_crash_log(&lines); term_println("crashlog: done"); } /// Set the display-store stall (cycles per word) - diagnostics knob that /// simulates the per-write cost of an uncached real-GPU framebuffer so /// the preemptive scheduler races long scroll pushes like on real /// hardware. `stall 0` disables (default; QEMU RAM framebuffers are /// instant). fn cmd_stall(p: &Parsed) { let n: u32 = p.args.first().and_then(|a| a.parse().ok()).unwrap_or(0); rsnix::gop::DISPLAY_STALL.store(n, core::sync::atomic::Ordering::Relaxed); term_println(&alloc::format!("stall: display store stall = {}", n)); } fn cmd_clear() { rsnix::gop::clear_screen(); } fn cmd_info(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let mut s = String::from("rs-dos v"); s.push_str(env!("CARGO_PKG_VERSION")); s.push_str(" - rsnix kernel"); let _ = pipe.line(&s); let _ = pipe.line("arch: x86_64, freestanding, no userspace"); pipe.end(); } fn cmd_ls(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or("/"); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] match rsnix::fs::resolve(path) { None => term_err(&alloc::format!("ls: {}: no such file or directory", path)), Some(h) => { let list = if rsnix::fs::kind(h) == rsnix::fs::FileType::Dir { rsnix::fs::children(h) } else { alloc::vec![( path.rsplit('/').next().unwrap_or(path).to_string(), rsnix::fs::kind(h), rsnix::fs::size(h) )] }; for (name, kind, size) in list { let l = match kind { rsnix::fs::FileType::Dir => alloc::format!("{}/", name), rsnix::fs::FileType::Char => alloc::format!("{}@", name), rsnix::fs::FileType::Regular => alloc::format!("{} {} B", name, size), }; if !pipe.line(&l) { break; } } } } pipe.end(); } // --------------------------------------------------------------------------- // klog - kernel log (thin alias for `cat /dev/klog`) // --------------------------------------------------------------------------- fn cmd_klog(p: &Parsed) { if !p.args.is_empty() { term_err("klog: usage: klog [-h N] [-t N] [-g PAT] [-p] [WRITING | TO ]"); return; } // Alias for `cat /dev/klog` - same filters and redirection, so // `klog -t 5` shows the last 5 log lines and `klog WRITING // /disk/.../boot.log` persists the boot log. Input keywords were // already rejected (klog does not accept input). let q = Parsed { cmd: alloc::string::String::from("klog"), args: alloc::vec!["/dev/klog".into()], input: Input::None, filters: p.filters.clone(), output: p.output.clone(), }; cmd_cat(&q); } // --------------------------------------------------------------------------- // cat - file and stream consumers // --------------------------------------------------------------------------- /// How a file read ended: read to EOF, stopped at the TIL marker, or /// aborted (^C / head kill) - the caller should stop processing paths. enum ReadResult { Done, Marker, Aborted, } fn cmd_cat(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] match &p.input { Input::None => { if p.args.is_empty() { // Path-less `cat` with no input keyword reads the keyboard // until ^D. cat_stdin(&mut pipe, None); } else { for path in &p.args { if !cat_file(&mut pipe, path) { break; } } } } Input::Use(path) => { cat_file(&mut pipe, path); } Input::Til(marker) => { if p.args.is_empty() { cat_stdin(&mut pipe, Some(marker)); } else { // `cat TIL ` - the paths are the input // source, truncated at the first marker line. for path in &p.args { match cat_file_until(&mut pipe, path, marker) { ReadResult::Done => {} ReadResult::Marker | ReadResult::Aborted => break, } } } } Input::UseTil(path, marker) => { cat_file_until(&mut pipe, path, marker); } } pipe.end(); } /// Read lines from the tty until ^C/^D (or an exact marker line, when /// `marker` is Some - the marker itself is not part of the input). fn cat_stdin(pipe: &mut Pipeline, marker: Option<&str>) -> bool { let term = rsnix::vt::home_terminal(); loop { match tty::read_line(term) { None => return true, // window closed = EOF Some(LineInput::Line(l)) => { if marker.is_some() && l.as_str() == marker.unwrap() { return true; } if !pipe.line(l.as_str()) { return false; } } Some(LineInput::Interrupted) => return false, // ^C aborts the stream Some(LineInput::Eof) => return true, // ^D = EOF } } } fn cat_file(pipe: &mut Pipeline, path: &str) -> bool { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("cat: {}: no such file", path)); return true; // keep going with the remaining paths } }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); loop { if tty::interrupt_pressed() { return false; // ^C aborts everything } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); if !pipe.line(line) { return false; } } } } } if !pending.is_empty() && !pipe.line(&pending) { return false; } true } fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("cat: {}: no such file", path)); return ReadResult::Done; } }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); let mut stopped = false; 'outer: loop { if tty::interrupt_pressed() { return ReadResult::Aborted; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); // Marker line and everything after is discarded. if line == marker { stopped = true; break 'outer; } if !pipe.line(line) { return ReadResult::Aborted; } } } } } if !stopped && !pending.is_empty() && pending != marker && !pipe.line(&pending) { return ReadResult::Aborted; } if stopped { ReadResult::Marker } else { ReadResult::Done } } // --------------------------------------------------------------------------- // hexdump - hex-dump files and input streams // --------------------------------------------------------------------------- struct HexCtx<'a> { pipe: &'a mut Pipeline, pos: u64, row: [u8; 16], row_len: usize, alive: bool, } fn format_partial(addr: u64, row: &[u8]) -> String { let mut out = String::new(); use core::fmt::Write as _; let _ = core::write!(out, "{:08x} ", addr); for i in 0..16 { if i < row.len() { let _ = core::write!(out, "{:02x} ", row[i]); } else { out.push_str(" "); } } out.push_str(" |"); for &b in row { out.push(if b.is_ascii_graphic() || b == b' ' { b as char } else { '.' }); } out.push('|'); out } impl HexCtx<'_> { fn feed(&mut self, data: &[u8]) { if !self.alive { return; } for &b in data { self.row[self.row_len] = b; self.row_len += 1; if self.row_len == 16 { let line = format_partial(self.pos, &self.row); self.alive = self.pipe.line(&line); self.pos += 16; self.row_len = 0; if !self.alive { return; } } } } /// Emit the trailing partial row, if any. fn flush(&mut self) { if self.alive && self.row_len > 0 { let line = format_partial(self.pos, &self.row[..self.row_len]); let _ = self.pipe.line(&line); } } } fn cmd_hexdump(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] match &p.input { Input::None => { if p.args.is_empty() { hexdump_stdin(&mut pipe, None); } else { for path in &p.args { if !hexdump_file(&mut pipe, path) { break; } } } } Input::Use(path) => { hexdump_file(&mut pipe, path); } Input::Til(marker) => { if p.args.is_empty() { hexdump_stdin(&mut pipe, Some(marker)); } else { // `hexdump TIL ` - the paths are the input // source, truncated at the first marker line. for path in &p.args { match hexdump_file_until(&mut pipe, path, marker) { ReadResult::Done => {} ReadResult::Marker | ReadResult::Aborted => break, } } } } Input::UseTil(path, marker) => { hexdump_file_until(&mut pipe, path, marker); } } pipe.end(); } fn hexdump_file(pipe: &mut Pipeline, path: &str) -> bool { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("hexdump: {}: no such file", path)); return true; } }; let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; let mut buf = [0u8; 512]; let mut pos = 0u64; loop { if tty::interrupt_pressed() { ctx.flush(); return false; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; ctx.feed(&buf[..n]); if !ctx.alive { break; } } } } ctx.flush(); ctx.alive } /// Hex-dump lines from the tty until ^C/^D (or an exact marker line, /// when `marker` is Some - the marker itself is not dumped). fn hexdump_stdin(pipe: &mut Pipeline, marker: Option<&str>) { let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; let term = rsnix::vt::home_terminal(); loop { match tty::read_line(term) { None => break, Some(LineInput::Line(l)) => { if marker.is_some() && l.as_str() == marker.unwrap() { break; } ctx.feed(l.as_str().as_bytes()); if !ctx.alive { break; } ctx.feed(b"\n"); if !ctx.alive { break; } } Some(LineInput::Interrupted) => break, Some(LineInput::Eof) => break, } } ctx.flush(); } fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("hexdump: {}: no such file", path)); return ReadResult::Done; } }; let mut ctx = HexCtx { pipe, pos: 0, row: [0u8; 16], row_len: 0, alive: true, }; let mut buf = [0u8; 512]; let mut pos = 0u64; let mut pending = String::new(); let mut stopped = false; 'outer: loop { if tty::interrupt_pressed() { break; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, Ok(n) => { pos += n as u64; pending.push_str(&String::from_utf8_lossy(&buf[..n])); while let Some(idx) = pending.find('\n') { let line: String = pending.drain(..=idx).collect(); let line = line.trim_end_matches('\n'); if line == marker { stopped = true; break 'outer; } ctx.feed(line.as_bytes()); ctx.feed(b"\n"); if !ctx.alive { break 'outer; } } } } } if ctx.alive && !stopped && !pending.is_empty() && pending != marker { ctx.feed(pending.as_bytes()); } ctx.flush(); if !ctx.alive { ReadResult::Aborted } else if stopped { ReadResult::Marker } else { ReadResult::Done } } // --------------------------------------------------------------------------- // Other commands // --------------------------------------------------------------------------- fn cmd_mkdir(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: mkdir "); return; } match rsnix::fs::mkdir(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!("mkdir: cannot create directory '{}'", path)), } } fn cmd_touch(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: touch "); return; } match rsnix::fs::touch(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!("touch: cannot create '{}'", path)), } } fn cmd_rm(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: rm "); return; } match rsnix::fs::remove_path(path) { Ok(()) => {} Err(()) => term_err(&alloc::format!( "rm: cannot remove '{}' (missing, dir not empty, or busy)", path )), } } fn cmd_edit(p: &Parsed) { let path = p.args.first().map(|s| s.as_str()).unwrap_or(""); if path.is_empty() { term_err("usage: edit "); return; } if rsnix::fs::ensure_file(path).is_err() { term_err(&alloc::format!("edit: cannot create {}", path)); return; } rsnix::gop::clear_screen(); rsdosedit::run(path); rsnix::gop::clear_screen(); } fn cmd_draw(_p: &Parsed) { match rsnix::sched::spawn(rspaint::run, "paint") { Some(id) => term_println(&alloc::format!( "draw: paint task {} — F4 or the close button to close", id )), None => term_err("draw: task table full"), } } fn cmd_term(_p: &Parsed) { // Debug: check if the callback is registered. let cb_set = unsafe { core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB) .read() .is_some() }; term_println(&alloc::format!("term: SPAWN_TERMINAL_CB set = {}", cb_set)); if !cb_set { term_err("term: callback not registered — cannot spawn"); return; } // Invoke the callback directly (same as F1). let (cw, ch) = rsnix::gop::pixel_dims(); let w = 640usize.min(cw); let h = 400usize.min(ch); term_println(&alloc::format!("term: requesting window {}x{}", w, h)); unsafe { let cb = core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB) .read() .unwrap(); cb("term dbg", w, h); } term_println("term: callback returned (task should be spawning)"); } fn cmd_windows(_p: &Parsed) { let list = rsnix::wm::list(); if list.is_empty() { term_println("windows: none (console only)"); return; } for (id, title, minimized) in list { term_println(&alloc::format!( " {} {}{}", id, title, if minimized { " (minimized)" } else { "" } )); } } fn cmd_restore(p: &Parsed) { let Some(id) = p.args.first().and_then(|s| s.parse::().ok()) else { term_err("usage: restore "); return; }; rsnix::wm::restore(id); } fn cmd_mem(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut total_kb = 0u64; let mut conventional_kb = 0u64; let mut loader_kb = 0u64; let mut runtime_kb = 0u64; let mut boot_code_kb = 0u64; let mut boot_data_kb = 0u64; let mut reserved_kb = 0u64; let mut acpi_kb = 0u64; let mut mmio_kb = 0u64; for desc in map { let pages = desc.number_of_pages; let bytes_4k = pages * 4; total_kb += bytes_4k; match desc.kind { 1 | 2 => loader_kb += bytes_4k, // LoaderCode + LoaderData 3 => boot_code_kb += bytes_4k, 4 => boot_data_kb += bytes_4k, 5 | 6 => runtime_kb += bytes_4k, // RuntimeCode + RuntimeData 7 => conventional_kb += bytes_4k, 9 | 10 => acpi_kb += bytes_4k, // ACPIReclaim + ACPINVS 11 | 12 => mmio_kb += bytes_4k, _ => reserved_kb += bytes_4k, // Reserved, Unusable, Pal, ... } } let h = rsnix::memory::heap_stats(); let ps = rsnix::memory::page_stats(); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let _ = pipe.line(&alloc::format!( "memory map (key=0x{:x}, {} entries)", key, map.len() )); let _ = pipe.line(&alloc::format!( " usable {:4} MB Conventional", conventional_kb / 1024 )); let _ = pipe.line(&alloc::format!( " boot code {:4} MB BootServicesCode", boot_code_kb / 1024 )); let _ = pipe.line(&alloc::format!( " boot data {:4} MB BootServicesData", boot_data_kb / 1024 )); let _ = pipe.line(&alloc::format!( " loader {:4} MB LoaderCode + LoaderData", loader_kb / 1024 )); let _ = pipe.line(&alloc::format!( " runtime {:4} MB RuntimeServices", runtime_kb / 1024 )); let _ = pipe.line(&alloc::format!(" ACPI {:4} MB", acpi_kb / 1024)); let _ = pipe.line(&alloc::format!(" MMIO {:4} MB", mmio_kb / 1024)); let _ = pipe.line(&alloc::format!(" reserved {:4} MB", reserved_kb / 1024)); let _ = pipe.line(&alloc::format!( " raw total {:4} MB (incl. PCIe MMIO)", total_kb / 1024 )); let _ = pipe.line("heap:"); let _ = pipe.line(&alloc::format!( " range {:#x}..{:#x} {} KB total", h.heap_start, h.heap_end, h.heap_size / 1024 )); let _ = pipe.line(&alloc::format!( " free {} KB in {} block(s), allocated {} KB", h.free_bytes / 1024, h.free_blocks, h.heap_size.saturating_sub(h.free_bytes) / 1024 )); let _ = pipe.line("pages:"); let _ = pipe.line(&alloc::format!( " span base {:#x}, {} MB covered", ps.base_page * rsnix::memory::PAGE_SIZE, ps.span_bytes / 1024 / 1024 )); let _ = pipe.line(&alloc::format!( " free {} pages ({} MB), {} used", ps.free_pages, ps.free_pages * rsnix::memory::PAGE_SIZE / 1024 / 1024, ps.used_pages )); pipe.end(); } // --------------------------------------------------------------------------- // mmap - dump every entry of the UEFI memory map with type names // --------------------------------------------------------------------------- fn cmd_mmap(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let _ = pipe.line(&alloc::format!( "memory map (key=0x{:x}, {} entries):", key, map.len() )); let _ = pipe.line(" # type start pages size"); for (i, d) in map.iter().enumerate() { let l = alloc::format!( " {:>2} {:<16} {:#014x} {:>9} {:>8} KB", i, rsnix::uefi::memory_type_name(d.kind), d.physical_start, d.number_of_pages, d.number_of_pages * 4 ); if !pipe.line(&l) { break; } } pipe.end(); } // --------------------------------------------------------------------------- // stress - allocate blocks of KB, hold them, then free all. // Pushes the heap past its base region so growth into the page allocator // is exercised; also verifies a clean return to the free-list after. // --------------------------------------------------------------------------- fn cmd_stress(p: &Parsed) { let mut it = p.args.iter(); let chunks: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if (1..=64).contains(&n) => n, _ => { term_err("usage: stress "); return; } }; let kb: usize = match it.next().and_then(|s| s.parse().ok()) { Some(n) if n > 0 && n <= 1024 * 1024 => n, _ => { term_err("usage: stress "); return; } }; let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let mut allocated = 0usize; { let mut blocks: [Option>; 64] = core::array::from_fn(|_| None); for i in 0..chunks { let mut b = alloc::vec::Vec::::new(); match b.try_reserve_exact(kb * 1024) { Ok(()) => { b.resize(kb * 1024, 0x42); blocks[i] = Some(b); allocated += 1; } Err(_) => { let _ = pipe.line(&alloc::format!(" block {}: out of memory, stopping", i)); break; } } } let h = rsnix::memory::heap_stats(); let ps = rsnix::memory::page_stats(); let _ = pipe.line(&alloc::format!( " held {} x {} KB = {} KB", allocated, kb, allocated * kb )); let _ = pipe.line(&alloc::format!( " heap free {} KB ({} blocks), pages free {} MB", h.free_bytes / 1024, h.free_blocks, ps.free_pages * 4 / 1024 )); // blocks dropped here → everything freed } let h = rsnix::memory::heap_stats(); let _ = pipe.line(&alloc::format!( " freed: heap free {} KB in {} blocks", h.free_bytes / 1024, h.free_blocks )); pipe.end(); } fn cmd_halt() -> ! { term_println("halting..."); rsnix::power::halt() } /// Debug the crash-family: free-list state + the historical crash /// offset (heap+0x443e0) + the shadow block's header. On the OptiPlex /// this shows whether the shadow span leaked into the free list and /// whether the probe region is zeroed/0xAA before any command runs. fn cmd_heapdbg(_p: &Parsed) { let h = rsnix::memory::heap_stats(); term_println(&alloc::format!( "heap: {:#x}..{:#x} cap={} MiB free={} KiB blocks={}", h.heap_start, h.heap_end, h.heap_size / 1048576, h.free_bytes / 1024, h.free_blocks )); for l in rsnix::memory::freelist_lines(12) { term_println(&l); } // fb vs heap overlap - OptiPlex crash hypothesis: the display's // framebuffer may sit inside the heap region (GPU BAR not reserved in // the EFI map), so display writes clobber live heap allocations. if let Some((fb, fsz)) = rsnix::gop::framebuffer_region() { let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end; term_println(&alloc::format!( "fb {:#x} size={:#x} overlap-heap={}", fb, fsz, ov )); } let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset match rsnix::memory::freelist_contains(probe) { Some((b, s, off)) => term_println(&alloc::format!( "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}", probe, b, s, off )), None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)), } let mut s = alloc::string::String::new(); unsafe { for i in 0..8 { if i > 0 { s.push(' '); } let v = *(probe as *const u32).add(i); s.push_str(&alloc::format!("{:08x}", v)); } } term_println(&alloc::format!("probe mem: {}", s)); // Scan the live shadow payload for 0x00aaaaaa words. NOTE: this is // the GRAY text color (rgb(170,170,170) = PALETTE[7] = fg 7), NOT a // firmware scrub - the "0x00AAAAAA fill" in the OptiPlex crash dumps // is just rendered text pixels. The scan's real value: untouched // shadow rows must be 0; non-zero far rows = out-of-bounds writes. if let Some((b, size, _)) = rsnix::gop::shadow_block_state() { let pay = b + 32; let words = (size.saturating_sub(32)) / 4; let mut n = 0u32; let mut first: [usize; 8] = [0; 8]; let mut nf = 0usize; unsafe { let mut i = 0usize; let cap = words.min(240_000); // bound the scan to avoid stalls while i < cap { let v = *(pay as *const u32).add(i); if v == 0x00aa_aaaa { if nf < first.len() { first[nf] = i; } nf += 1; n += 1; } i += 1; } } let mut fs = alloc::string::String::new(); for &f in first[..nf.min(8)].iter() { fs.push_str(&alloc::format!("@{}(r{}c{}) ", f, f / 800, (f % 800) / 8)); } term_println(&alloc::format!( "shadow scan: {}w/{} ({}%) {}", n, words, (n * 1000 / (words as u32).max(1)) / 10, fs )); // Probe far rows (untouched by text) to check the init zeroing. let mut d = alloc::string::String::new(); unsafe { for (label, off) in [("r100", 80_000usize), ("r300", 240_000), ("r500", 400_000)] { let w0 = *(pay as *const u32).add(off); let w1 = *(pay as *const u32).add(off + 1); d.push_str(&alloc::format!(" {}={:08x}{:08x}", label, w0, w1)); } } term_println(&alloc::format!("shadow far: {}", d)); // Underlying cell/rendered values at the first 0x00aaaaaa (gray // text pixel) hit, if any: confirms the shadow word is the glyph // pixel of a fg=7 cell (consistent) vs a shadow write outside // the vt path (inconsistent). if nf > 0 { let f = first[0]; let tr = f / 800 / 16; let tc = (f / 8) % 100; let (rc, rr) = rsnix::vt::cell_raw(tr, tc); term_println(&alloc::format!( "shadow@{:x} cell={:08x} rendered={:08x}", f, rc, rr )); } } match rsnix::gop::shadow_block_state() { Some((b, size, free)) => { term_println(&alloc::format!( "shadow blk {:#x} size={:#x} free={}", b, size, free )); match rsnix::memory::freelist_contains(b) { Some((fb, fs, fo)) => term_println(&alloc::format!( "shadow base IN free list: blk {:#x} size={:#x} off={:#x}", fb, fs, fo )), None => term_println("shadow base NOT in free list"), } } None => term_println("shadow: NONE (no-shadow mode)"), } } fn cmd_date(p: &Parsed) { let t = rsnix::rtc::now(); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let line = alloc::format!( "{}-{:02}-{:02} {:02}:{:02}:{:02}", t.year, t.month, t.day, t.hour, t.minute, t.second ); let _ = pipe.line(&line); pipe.end(); } /// Deep device re-scan: AHCI ports → FAT volumes → VFS mount table. /// Returns (disks added, disks removed, vols mounted, vols unmounted, /// nodes added, nodes removed) so callers can report the result. fn rescan_all() -> (usize, usize, usize, usize, usize, usize) { let (da, dr) = rsnix::ahci::rescan(); let (ma, mu) = rsnix::fat::rescan(); let (na, nr) = rsnix::fs::rescan_disks(); (da, dr, ma, mu, na, nr) } /// Print the one-line change summary; returns true if anything changed. fn report_rescan((da, dr, ma, mu, na, nr): (usize, usize, usize, usize, usize, usize)) -> bool { term_println(&alloc::format!( "rescan: {} disk(s) added, {} removed · {} volume(s) mounted, {} unmounted · {} node(s) added, {} removed", da, dr, ma, mu, na, nr )); da + dr + ma + mu + na + nr > 0 } fn cmd_rescan(_p: &Parsed) { if report_rescan(rescan_all()) { term_println("rescan done - /disk updated"); } else { term_println("rescan done - nothing changed"); } } /// Mouse front-end. The cursor task owns the PS/2 ring, so this /// command *watches the on-screen cursor* (position + buttons, printed /// only on change) until ^C. With a task id it also routes every event /// to that task's queue - the window-manager input path - clearing the /// route on exit. fn cmd_mouse(p: &Parsed) { let target: Option = p.args.first().and_then(|s| s.parse::().ok()); if let Some(id) = target { if !rsnix::sched::is_alive(id) { term_err(&alloc::format!("mouse: no such task {}", id)); return; } rsnix::cursor::set_forward(Some(id)); term_println(&alloc::format!( "mouse: routing events to task {} (cursor still tracks) - ^C to stop", id )); } else if !rsnix::ps2::mouse_present() { term_err("mouse: no PS/2 mouse at boot - cat /dev/mouse still works"); return; } else { term_println("mouse: watching cursor - move the mouse, ^C to stop"); } let mut last = (usize::MAX, usize::MAX, u8::MAX); loop { if tty::interrupt_pressed() { rsnix::cursor::set_forward(None); term_println("mouse: stopped"); return; } let (x, y, b) = ( rsnix::gop::cursor_pos().0, rsnix::gop::cursor_pos().1, rsnix::gop::cursor_buttons(), ); if (x, y, b) != last { last = (x, y, b); term_println(&alloc::format!( "mouse: x={} y={} buttons={}{}{}", x, y, if b & 1 != 0 { 'L' } else { '-' }, if b & 2 != 0 { 'R' } else { '-' }, if b & 4 != 0 { 'M' } else { '-' } )); } rsnix::sched::sleep_ms(10); } } /// One-shot cursor state (parseable; used by the test harness). fn cmd_cursor(_p: &Parsed) { let (x, y) = rsnix::gop::cursor_pos(); let b = rsnix::gop::cursor_buttons(); term_println(&alloc::format!( "cursor: x={} y={} buttons={}{}{}", x, y, if b & 1 != 0 { 'L' } else { '-' }, if b & 2 != 0 { 'R' } else { '-' }, if b & 4 != 0 { 'M' } else { '-' } )); } /// Graphics primitives demo + pixel self-test (shadow + display + cursor /// overlay readbacks). `gfx` / `gfx demo` draws a status-bar scene; /// `gfx check` runs the readback assertions (run after `clear` for a /// predictable background). fn cmd_gfx(p: &Parsed) { match p.args.first().map(|s| s.as_str()) { Some("check") => gfx_check(), _ => gfx_demo(), } } fn gfx_demo() { let (w, h) = rsnix::gop::pixel_dims(); if w == 0 { term_err("gfx: no framebuffer"); return; } // Status-bar strip across the bottom, with three RGB boxes. rsnix::gop::fill_rect(0, h - 40, w, 40, rsnix::gop::rgb(0, 0, 128)); rsnix::gop::fill_rect(20, h - 32, 60, 24, rsnix::gop::rgb(255, 0, 0)); rsnix::gop::fill_rect(90, h - 32, 60, 24, rsnix::gop::rgb(0, 255, 0)); rsnix::gop::fill_rect(160, h - 32, 60, 24, rsnix::gop::rgb(255, 255, 0)); term_println(&alloc::format!( "gfx: drew status bar + RGB boxes ({}x{} px bottom strip)", w, h )); } fn gfx_check() { let (w, _h) = rsnix::gop::pixel_dims(); if w == 0 { term_err("gfx: no framebuffer"); return; } let mut ok = true; let mut report = String::new(); // 1. fill_rect lands in the shadow. let (rx, ry, rw, rh) = (8usize, 8usize, 40usize, 16usize); let red = rsnix::gop::rgb(255, 0, 0); rsnix::gop::fill_rect(rx, ry, rw, rh, red); match rsnix::gop::shadow_pixel(rx + rw / 2, ry + rh / 2) { Some(p) if p == red => report.push_str("fill_rect/shadow: OK\n"), Some(p) => { ok = false; report.push_str(&alloc::format!("fill_rect/shadow: FAIL ({:#x})\n", p)); } None => { ok = false; report.push_str("fill_rect/shadow: FAIL (no shadow)\n"); } } // 2. the push reached the display. match rsnix::gop::display_pixel(rx + rw / 2, ry + rh / 2) { Some(p) if p == red => report.push_str("display push: OK\n"), Some(p) => { ok = false; report.push_str(&alloc::format!("display push: FAIL ({:#x})\n", p)); } None => { ok = false; report.push_str("display push: FAIL (readback)\n"); } } // 3. cursor overlay: the arrow tip is white on the display… let (cx, cy) = rsnix::gop::cursor_pos(); let (dw, dh) = rsnix::gop::pixel_dims(); let white = rsnix::gop::rgb(255, 255, 255); let tip = rsnix::gop::display_pixel(cx + 2, cy); let out = rsnix::gop::display_pixel(cx, cy); match (tip, out) { (Some(t), Some(o)) if t == white && o != white => report.push_str("cursor overlay: OK\n"), (Some(t), Some(o)) => { ok = false; report.push_str(&alloc::format!( "cursor overlay: FAIL (tip={:#x} outside={:#x})\n", t, o )); } _ => { ok = false; report.push_str(&alloc::format!( "cursor overlay: FAIL (readback cx={} cy={} dims={}x{})\n", cx, cy, dw, dh )); } } // …and it must NOT be in the shadow (pure overlay). match rsnix::gop::shadow_pixel(cx + 2, cy) { Some(p) if p != white => report.push_str("cursor not in shadow: OK\n"), p => { ok = false; report.push_str(&alloc::format!("cursor in shadow: FAIL ({:?})\n", p)); } } term_println(&report); term_println(if ok { "gfx check: PASS" } else { "gfx check: FAIL" }); } // --------------------------------------------------------------------------- // Multitasking: bg / jobs / sleep / kill / demo // // Background tasks are headless: they may sleep, yield, and log to // serial/klog only. Only the shell touches the disk, filesystem, and // terminal (the ADR 0003 invariant). // --------------------------------------------------------------------------- fn cmd_bg(p: &Parsed) { let Some(name) = p.args.first() else { term_err("bg: needs a task name - spinner, ticker, counter, boom, overflow"); return; }; let entry: fn() = match name.as_str() { "spinner" => task_spinner, "ticker" => task_ticker, "counter" => task_counter, "boom" => task_boom, "overflow" => task_overflow, _ => { term_err(&alloc::format!( "bg: unknown task '{}' (spinner, ticker, counter, boom, overflow)", name )); return; } }; match rsnix::sched::spawn(entry, name) { Some(id) => term_println(&alloc::format!("bg: spawned task {} ({})", id, name)), None => term_err("bg: task table full"), } } fn cmd_jobs(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let lines = rsnix::sched::jobs(); if lines.is_empty() { let _ = pipe.line("jobs: no tasks"); } for l in lines { if !pipe.line(&l) { break; } } pipe.end(); } /// Demo of the scheduler message queues: spawn a task that blocks on /// `recv_blocking` and logs every message it receives to serial. The /// shell then posts to it with `send …` (id from `jobs`). fn cmd_msgrecv(_p: &Parsed) { match rsnix::sched::spawn(task_msgrecv, "msgrecv") { Some(id) => term_println(&alloc::format!( "msgrecv: task {} listening - post with: send {} …", id, id )), None => term_err("msgrecv: task table full"), } } /// The recv demo body: block forever, log each message. Headless by /// design (serial only - it never touches tty/vt/fs, per ADR 0003). fn task_msgrecv() { loop { let msg = rsnix::sched::recv_blocking(); rsnix::serial_println!( "msgrecv[{}]: {}", rsnix::sched::current(), rsnix::sched::msg_describe(&msg) ); } } /// `send [args]` - post a TaskMsg to a task's queue. This /// is the shell-side knob for the primitive the window manager will use /// to route input; types: user , timer, mouse /// (1=L 2=R 4=M), key (enter esc tab space bksp up down left /// right home end pgup pgdn del ctrl-c f1..f12, or a single char). fn cmd_send(p: &Parsed) { let mut args = p.args.iter(); let id: usize = match args.next().and_then(|s| s.parse().ok()) { Some(v) => v, None => { term_err("send: needs a task id - jobs lists them, msgrecv spawns a listener"); return; } }; let kind = match args.next() { Some(k) => k.as_str(), None => { term_err("send: needs a type - user | timer | mouse | key "); return; } }; let msg = match kind { "user" => { let Some(n) = args.next().and_then(|s| s.parse().ok()) else { term_err("send: user needs a number"); return; }; rsnix::sched::TaskMsg::User(n) } "timer" => rsnix::sched::TaskMsg::Timer, "mouse" => { let dx: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); let dy: i16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); let btn: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0); rsnix::sched::TaskMsg::Mouse(rsnix::ps2::MouseEvent { dx, dy, left: btn & 1 != 0, right: btn & 2 != 0, middle: btn & 4 != 0, }) } "key" => match args.next() { Some(k) => match key_from_name(k.as_str()) { Some(ev) => rsnix::sched::TaskMsg::Key(ev), None => { term_err("send: unknown key name"); return; } }, None => { term_err("send: key needs a name"); return; } }, _ => { term_err("send: unknown type - user | timer | mouse | key "); return; } }; match rsnix::sched::send(id, msg) { Ok(()) => {} Err(e) => term_err(&alloc::format!("send: {}", e)), } } /// Map a small key-name vocabulary to a KeyEvent for `send key …`. fn key_from_name(name: &str) -> Option { use rsnix::ps2::KeyEvent; Some(match name { "enter" => KeyEvent::Enter, "esc" | "escape" => KeyEvent::Escape, "tab" => KeyEvent::Tab, "space" => KeyEvent::Char(' '), "bksp" | "backspace" => KeyEvent::Backspace, "up" => KeyEvent::ArrowUp, "down" => KeyEvent::ArrowDown, "left" => KeyEvent::ArrowLeft, "right" => KeyEvent::ArrowRight, "home" => KeyEvent::Home, "end" => KeyEvent::End, "pgup" => KeyEvent::PageUp, "pgdn" => KeyEvent::PageDown, "del" | "delete" => KeyEvent::Delete, "ctrl-c" => KeyEvent::Ctrl('c'), "ctrl-d" => KeyEvent::Ctrl('d'), "ctrl-s" => KeyEvent::Ctrl('s'), "ctrl-x" => KeyEvent::Ctrl('x'), _ => { if let Some(n) = name.strip_prefix('f') { if let Ok(n) = n.parse::() { if (1..=12).contains(&n) { KeyEvent::F(n) } else { return None; } } else { return None; } } else if name.chars().count() == 1 { KeyEvent::Char(name.chars().next().unwrap()) } else { return None; } } }) } fn cmd_sleep(p: &Parsed) { let ms: u64 = match p.args.first().and_then(|s| s.parse().ok()) { Some(m) if m > 0 => m, _ => { term_err("sleep: needs a duration in ms (e.g. sleep 2000)"); return; } }; term_println(&alloc::format!( "sleep: blocking shell for {} ms - background tasks keep running", ms )); rsnix::sched::sleep_ms(ms); term_println(&alloc::format!("sleep: woke after {} ms", ms)); } fn cmd_kill(p: &Parsed) { let id: usize = match p.args.first().and_then(|s| s.parse().ok()) { Some(id) => id, None => { term_err("kill: needs a task id (see jobs)"); return; } }; match rsnix::sched::kill(id) { Ok(()) => term_println(&alloc::format!("kill: task {} marked done", id)), Err(e) => term_err(&alloc::format!("kill: {}", e)), } } fn cmd_demo(_p: &Parsed) { let mut n = 0; for (entry, name) in [ (task_spinner as fn(), "spinner"), (task_ticker as fn(), "ticker"), (task_counter as fn(), "counter"), ] { if rsnix::sched::spawn(entry, name).is_some() { n += 1; } } term_println(&alloc::format!( "demo: spawned {} task(s) - spinner 250ms, ticker 500ms, counter 1000ms (try jobs)", n )); } // --- Demo task bodies (headless: sleep + serial/klog only) --- fn task_spinner() { let mut n: u64 = 0; loop { rsnix::serial_println!("spinner: {}", n); n += 1; rsnix::sched::sleep_ms(250); } } fn task_ticker() { let mut n: u64 = 0; loop { rsnix::serial_println!("ticker: {}", n); n += 1; rsnix::sched::sleep_ms(500); } } fn task_counter() { let mut n: u64 = 0; loop { rsnix::serial_println!("counter: {}", n); n += 1; rsnix::sched::sleep_ms(1000); } } fn task_boom() { panic!("boom: deliberate task panic"); } /// Recurses until the guard page faults - the task should die with a /// "stack overflow" panic while the shell keeps running. fn task_overflow() { #[allow(unconditional_recursion)] fn recurse(d: u64) { let keep = [0u8; 512]; core::hint::black_box(&keep); recurse(d + 1); } recurse(0); } fn usb_if_label(c: u8, s: u8, p: u8) -> &'static str { if c == 0x0C && s == 0x03 { match p { 0x00 => "UHCI", 0x10 => "OHCI", 0x20 => "EHCI", 0x30 => "xHCI", _ => "USB*", } } else { "" } } fn cmd_pci(p: &Parsed) { let devices = rsnix::pci::probe(); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let _ = pipe.line(&alloc::format!("pci: {} device(s)", devices.len())); let _ = pipe.line(" bus dev fn class vendor:device if"); for d in &devices { let l = alloc::format!( " {:02x}:{:02x}.{} {:<16} {:04x}:{:04x} {}", d.bus, d.device, d.func, rsnix::pci::class_name(d.class, d.subclass), d.vendor, d.device_id, usb_if_label(d.class, d.subclass, d.prog_if) ); if !pipe.line(&l) { break; } } pipe.end(); } fn cmd_usb(p: &Parsed) { // `usb rescan` clears the failed-port masks so discovery retries. if p.args.first().is_some_and(|w| w == "rescan") { rsnix::usb::rescan(); rsnix::ehci::rescan(); let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] let _ = pipe.line("usb: rescanning ports"); pipe.end(); return; } let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] for l in rsnix::usb::diagnose().lines() { if !pipe.line(l) { break; } } pipe.end(); // EHCI (USB2) controller status - the real hardware path. let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] for l in rsnix::ehci::diagnose().lines() { if !pipe.line(l) { break; } } pipe.end(); // UHCI (USB1.1 companion) — full/low-speed keyboards and mice. let mut pipe = Pipeline::new(&p.filters, &p.output); #[allow(unused_mut)] for l in rsnix::uhci::diagnose().lines() { if !pipe.line(l) { break; } } pipe.end(); } fn cmd_poweroff() -> ! { term_println("shutting down..."); rsnix::power::poweroff() }