diff --git a/Cargo.toml b/Cargo.toml index 74ff9e5..57c815f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["iris-gui"] + [package] name = "iris" version = "0.1.0" diff --git a/iris-gui-README.md b/iris-gui-README.md new file mode 100644 index 0000000..93c8b2e --- /dev/null +++ b/iris-gui-README.md @@ -0,0 +1,433 @@ +# iris-gui + +An optional egui-based front-end for the iris SGI Indy emulator. Provides a +menu-driven launcher and configuration UI on top of the existing `iris` core +library, with named-machine storage, auto-save, and panic-safe runtime +control. + +iris-gui is a separate workspace crate. A plain `cargo build` of the repo +still builds only the standalone `iris` CLI — the GUI's dependencies +(`eframe` / `egui` / `rfd` plus the iris additive features) only land in the +build when you explicitly opt in. + +--- + +## 1. Build & run + +### Default build (recommended) + +``` +cargo run -p iris-gui --release +``` + +The first build is slow because iris-gui forces several heavyweight +*additive* iris features on so they're available at runtime: `chd` +(libchdman-rs), `camera` (nokhwa / AVFoundation), `jit` and `rex-jit` +(cranelift). Subsequent builds are fast. + +A debug build (`cargo run -p iris-gui` without `--release`) is fine for +iterating on the GUI itself but uses an unoptimized iris core, which means +emulation will be noticeably slow. + +### Adding compile-time-only features + +The features above are runtime-selectable (e.g. `.chd` paths and +`vino.source = "camera"`). A second class of iris features is +**compile-time pervasive** — they change how the executor itself is built +and can't be toggled at runtime. To opt in, pass them through the iris dep: + +``` +cargo build -p iris-gui --release --features iris/lightning,iris/r5k +``` + +Notable Group-B features: +- `iris/lightning` — strips breakpoint checks + traceback for ~5% speed. + Interactive debugging (GDB stub) is non-functional in this build. The + GUI detects this at runtime (`iris::build_features::LIGHTNING`) and + greys out the GDB port input on the Debug/JIT tab. +- `iris/r5k`, `iris/r5ksc`, `iris/r5ksc_triton` — switch the emulated CPU + to an R5000 with 2-way caches (default is R4400 direct-mapped). +- `iris/tlbstats`, `iris/ci_clock`, `iris/gdc`, `iris/mouseabs`, + `iris/developer*`, `iris/debug_cache` — various core tweaks. + +The **Help → Build features** menu lists what's compiled in. + +### Verifying that the default iris build is unaffected + +``` +cargo build --release # builds just the iris binary +cargo tree -p iris | grep -E 'egui|eframe|rfd' # should print nothing +``` + +--- + +## 2. First run + +On a cold start (no `gui.json`), the **New machine** dialog opens +automatically. You'll be asked for: + +- **Name** — used for the machine entry in `gui.json`. Conflicts get a + numeric suffix (`indy`, `indy-2`, …). +- **PROM image** — defaults to "Use embedded PROM (bundled with iris)", + which lets iris fall back to its built-in PROM blob with no disk file + needed. Untick to point at your own `prom.bin`. +- **NVRAM file** — created on first write. +- **Total RAM** — preset 32 / 64 / 96 / 128 / 192 / 256 MB. Tick + **Advanced: configure individual banks** to set each of the four banks + yourself (valid sizes: 0, 8, 16, 32, 64, 128 MB). +- **Boot disk (SCSI #1)** — optional path, plus a "treat as empty for + fresh install" hint. +- **CD-ROM (SCSI #4)** — optional install media. + +Hit **Create**. The machine is saved to `gui.json` and becomes active. +Hit **▶ Start** to boot it. + +--- + +## 3. UI tour + +### Menu bar + +| Menu | Purpose | +| --- | --- | +| **File** | New machine… / Switch to machine → / Rename / Delete / Import iris.toml… / Export to iris.toml… / Quit | +| **Machine** | Start, Stop, Reset, Save state, Restore state, Screenshot | +| **Memory** | Total presets, plus per-bank submenus | +| **SCSI** | Per-ID submenu (SCSI #1 … #7) with context-appropriate actions | +| **View** | Fullscreen (F11), UI scale | +| **Help** | Version + build feature listing | + +The **SCSI** menu is the recommended way to attach / detach / replace +drives. Each ID shows its current state inline: + +- *(empty)*: Attach HDD… / Attach CD-ROM… / Create blank HDD image… +- *HDD attached*: Enable/Disable COW overlay / Replace image… / Detach +- *CD-ROM attached*: Eject / Insert disc… / Detach + +### Toolbar + +- **▶ Start** / **■ Stop** (Stop opens the safe-stop dialog if needed) +- **💾 Save state** / **↶ Restore state** — calls `Machine::save_snapshot` / `Machine::ci_restore` against `saves//` +- **Edit config… / Hide config editor** — toggles the collapsible config + side panel (see Central panel below) for advanced settings that aren't + surfaced as menu actions (Network, Video-In, Debug/JIT, CI) +- Right side: status pill (PROM / IRIX running / halted / stopped) and + MIPS counter + +### Central panel + +The central panel **always shows the emulator screen** once a machine is +running — the live REX3 framebuffer, drawn aspect-fit and centered. While +idle it falls back to the **welcome / status panel**: active machine name, +PROM/NVRAM/RAM summary, attached drive list, network mode, and the big +Start button. + +### Config side panel + +The **Edit config… / Hide config editor** toolbar toggle slides in a +collapsible **right-hand side panel** with the tabbed editor (Network / +Video-In / Debug/JIT / CI). It no longer covers the central panel, so you +can change settings while watching the emulator screen. The panel is +resizable — drag its left edge to trade width with the screen — and is +dismissed with either the toolbar toggle or the **✕** in its header. It +starts collapsed on each launch (open/closed state isn't persisted). + +The **Video-In** tab's source selector offers: + +- **off (disabled)** — the default. VINO stays memory-mapped (IRIX can + still probe it) but no video source is installed and the DMA pump thread + never starts. Use this unless you specifically need IndyCam. +- **test_pattern** — SMPTE-style colour bars + animated luma ramp. +- **camera** — live host camera (needs a `--features camera` build; first + use on macOS triggers the camera permission prompt). +- **black** — solid black field; drivers attach but no capture. + +### Keyboard shortcuts + +- **F11** — toggle fullscreen +- **Ctrl =** / **Ctrl -** / **Ctrl 0** — zoom in / out / reset (helps on + Linux/Wayland where egui's default text size can look small) + +--- + +## 4. Storage model + +### Where things live + +- `~/.config/iris/gui.json` — **the system of record.** Contains all + saved machines, the active machine pointer, window state, UI scale, + and fullscreen pref. +- `iris.toml` — the **standalone iris CLI's** config format. iris-gui + treats it as *import/export only* via the File menu, so a machine + configured in the GUI can still be booted with `cargo run -- --config + exported.toml`. + +### `gui.json` shape + +```json +{ + "ui_scale": 1.15, + "fullscreen": false, + "active_machine": "indy", + "machines": { + "indy": { "prom": "(embedded)", "nvram": "nvram.bin", ... }, + "irix-65": { ... } + }, + "recent_configs": [...], + "last_config": null +} +``` + +`MachineConfig` (defined in `iris/src/config.rs`) is serde-serialized +directly, so the schema follows the canonical iris config. Existing +`gui.json` files from earlier iris-gui builds upgrade automatically +(missing fields default to empty/None). + +### Autosave + +Every form field, dialog result, and menu action that mutates the config +calls `App::mark_dirty()`. This sets `cfg_dirty = true` and stamps +`cfg_dirty_since`. Each frame, `App::maybe_autosave()` checks the +timestamp and flushes after **~600 ms of inactivity** — debouncing +keystrokes without leaving you in a "did it save?" state. + +Hard flushes also occur: +- Before **Start** (so the on-disk copy matches what we're booting). +- On **Quit**. +- On **Rename current…** and **Switch to machine**. +- On **Import iris.toml…**. + +The welcome panel shows `(autosave pending…)` during the debounce window +and the status bar shows a trailing `*` next to the machine name. + +### Migration + +If you had an older iris-gui that pointed `last_config` at an +`iris.toml`, the first launch of the new build will import that TOML as +a named machine (using the file stem), clear the legacy pointer, and +adopt it as `active_machine`. No manual steps required. + +--- + +## 5. Crash & corruption safety + +The iris core occasionally calls `std::process::exit` directly. From a +GUI host that's terminal — `catch_unwind` can't intercept it. iris-gui +guards every reachable exit: + +| Exit site | When | iris-gui guard | +| --- | --- | --- | +| `machine.rs:291` SCSI attach fatal | configured image file missing | **Pre-flight**: `App::missing_disks()` runs before sending `Cmd::Start`. If any image is missing, a modal lists them with **Cancel / Edit Disks tab / Detach missing & start**. | +| `machine.rs:585` PowerOff handler | IRIX `halt` finishes | iris-gui sets **`IRIS_NO_EXIT_ON_POWEROFF=1`** in `main()`. iris reads it inside the PowerOff arm and skips the `exit(0)`. The machine is still `.stop()`'d cleanly. | +| `ci.rs:244` CI socket `quit` | future use | Same env-var guard. | +| Anywhere a `Machine` method panics | bad image, parse failure, etc. | **Worker thread `catch_unwind`** in `handle.rs::worker_loop` around `Machine::new`/`start`/`stop`. The worker stays alive and emits `Evt::Error("start failed: ")` instead. | + +Behavior of the standalone `iris` binary is unchanged — it doesn't set +the env var, so the soft-power-off `exit(0)` still happens as before. + +### Serial TCP ports & stale processes + +iris binds two TCP serial backends on fixed ports — +`127.0.0.1:8880` (channel A / tty2) and `127.0.0.1:8881` (channel B / +tty1, used by **Send IRIX halt**). Two consequences: + +- **You can't run two emulators at once.** A second instance can't bind + the same ports; its serial channels fall back to null backends (see + below). +- **A crashed run can orphan the ports.** If iris-gui dies without fully + tearing down (a hard crash, `kill -9`), the emulator threads may keep + the listeners open. The next start then hits `AddrInUse` on bind. + +Previously that bind failure `.expect()`-panicked and aborted the whole +process. It now fails soft: `TcpSocketBackend::new` logs a warning and +the channel falls back to a `NullBackend`, so the machine still boots — +that serial channel is just unavailable until the port frees. + +If you *need* the serial console back, clear the stale listener: + +``` +lsof -nP -iTCP:8880 -sTCP:LISTEN # find who's holding it +pkill -f iris-gui # or: kill from the line above +``` + +### Safe-stop dialog + +Clicking **Stop** invokes `safe_stop::evaluate`. The core does not expose +live dirty-sector state, so the decision is made **from config**: an abrupt +power-off only risks the on-disk image when some attached device writes +through to its **base image** — i.e. a plain read-write hard disk. If one is +attached, a modal lists the affected `scsi` IDs with **Cancel / Send IRIX +halt / Force stop**. + +If *nothing* persists guest writes to a base image, stopping won't damage the +hard disk, so the dialog is skipped and the machine powers off immediately. +These cases are all treated as safe: + +- **CD-ROM** — read-only. +- **COW overlay** (`overlay = true`) — writes go to a `{path}.overlay` + sidecar; the base image is never modified. +- **Scratch volume** (`scratch = true`) — a transient host-side file. +- **CHD** (`*.chd`) — writes go to a `.diff.chd` sidecar; the base CHD is + never modified. + +> The "Send IRIX halt" button is wired (TCP `127.0.0.1:8881` → `halt\n`). +> Note the heuristic is intentionally config-based, not runtime: it can't see +> *whether* the guest has actually written to a read-write disk, only that one +> is attached. Exposing `Machine::is_in_prom()` / `dirty_cow_sectors()` / +> event subscription would let the dialog also clear a read-write disk that's +> idle or already halted; see the Phase C notes at the bottom of this doc. + +### Stopping a wedged machine + +`Machine::stop()` begins with `cpu.stop()`, which blocks until the CPU thread +acknowledges the halt — a thoroughly wedged guest can make that never return. +To keep the GUI alive, the worker runs the stop on a detached helper thread +(`handle.rs::stop_machine_timed`) and waits at most **5 s**. On timeout it +emits an error toast and reports the machine as stopped so you regain control; +the wedged `Machine` and helper thread are abandoned (they leak, but the OS +reclaims them at exit). The same bound is applied on **Quit**, so closing the +app on a hung guest can't hang exit either. + +--- + +## 6. Architecture + +### Crate layout + +``` +iris/ +├── Cargo.toml [workspace] { members = ["iris-gui"] } +├── src/ iris library + CLI (unchanged) +└── iris-gui/ + ├── Cargo.toml depends on iris with chd, camera, jit, rex-jit on + └── src/ + ├── main.rs App, menu bar, toolbar, modals, update loop + ├── handle.rs EmulatorHandle: worker thread, command/event channels + ├── framebuffer.rs CaptureRenderer + FrameSink (REX3 → egui texture) + ├── input.rs egui → PS/2 keyboard+mouse pump + ├── config_ui.rs Tabbed editor (Network / Video-In / Debug / CI) + ├── scsi_menu.rs Top-level SCSI submenu (per-ID actions) + ├── safe_stop.rs Stop-safety evaluator + ├── settings.rs GuiSettings: gui.json read/write, machine map + └── dialogs/ + ├── mod.rs + ├── new_machine.rs Startup "New machine" dialog + └── create_disk.rs Blank-HDD-image creator +``` + +### Thread model + +``` + +------------------+ cmd_tx +------------------+ + | eframe / egui | ---------------------> | worker thread | + | (main thread) | | (handle.rs) | + | | <--------------------- | | + +------------------+ evt_rx +------------------+ + owns (crossbeam) owns + GuiSettings, Option + MachineConfig, + catch_unwind + egui::Context around all calls +``` + +- The eframe app owns the single `winit::EventLoop` for the process. + iris's own `src/ui.rs` event loop is **not** used — iris-gui never + calls `Ui::run`, so iris never opens its own winit window. REX3 still + runs its refresh loop; we just intercept the per-frame output via a + custom `Renderer` impl (`framebuffer.rs::CaptureRenderer`) installed + into `Rex3::renderer` before the CPU starts. The captured pixels are + uploaded to an `egui::TextureHandle` and drawn aspect-fit in the + central panel. +- A dedicated worker thread (8 MB stack to satisfy + `Physical::device_map`'s allocation) owns the `Machine` when one + exists. Commands are sent over a `crossbeam_channel::Sender`; + events come back over a `Receiver` that the main thread drains + each frame. +- Input flows the other way: the GUI thread reads egui events each + frame and, when the cursor is over the framebuffer rect, drives the + guest's `Ps2Controller` directly via a handle shared from the worker + through `EmulatorHandle.ps2: Arc>>>`. + +### Command / event vocabulary + +``` +enum Cmd { Start(MachineConfig), Stop, SaveState(name), RestoreState(name), + Screenshot(path), Quit } +enum Evt { Started, Stopped, PowerOff, StateSaved(name), StateRestored(name), + Screenshot(path), Error(msg), Status(Status) } +``` + +`Status` carries `running`, `in_prom`, `power_off_seen`, `dirty_cow`, +`mips`. The worker currently emits `Started` / `Stopped` / `StateSaved` +/ `StateRestored` / `Screenshot` / `Error`. `PowerOff` and `Status` are +reserved for when the iris core exposes `Machine::subscribe_events` and +status accessors (see Phase C notes). + +### Build-time feature detection + +`iris/src/lib.rs` exposes a `build_features` module: + +```rust +pub mod build_features { + pub const CHD: bool = cfg!(feature = "chd"); + pub const CAMERA: bool = cfg!(feature = "camera"); + pub const JIT: bool = cfg!(feature = "jit"); + pub const REX_JIT: bool = cfg!(feature = "rex-jit"); + pub const LIGHTNING: bool = cfg!(feature = "lightning"); +} +``` + +iris-gui reads these to: +- Surface the active feature set in **Help → Build features**. +- Warn on the **Disks** form when a `.chd` path is entered into a + non-CHD build (currently always on for iris-gui). +- Hide the GDB stub port input on the Debug/JIT tab under a lightning + build (interactive debugging is non-functional there). +- Adjust the "camera" label on the Video-In tab. + +### Conventions + +- `Result` for fallible APIs (matches in-tree iris style; no + anyhow / thiserror). +- `log` macros for diagnostics; the existing iris `devlog` module + remains the routing layer. +- `crossbeam-channel` for cross-thread messaging. +- `parking_lot::Mutex` where sharing is needed. +- No new `rustfmt.toml` / `clippy.toml`; defaults apply. + +A short companion note lives at `rules/gui/01-overview.md` per the +CLAUDE.md convention for hard-won emulator findings. + +--- + +## 7. Phase B status + +Phase B is live. iris-gui now hosts the emulator in-process with the +REX3 framebuffer rendered inside the egui central panel and keyboard / +mouse routed through to the guest. + +| Item | Status | Where | +| --- | --- | --- | +| Embedded REX3 framebuffer | ✅ landed | `framebuffer.rs::CaptureRenderer` installed in `Rex3::renderer`. Frame copied per `render()` call into a `FrameSink`. GUI uploads to an `egui::TextureHandle` on change and draws aspect-fit, centered. | +| egui → PS/2 input | ✅ landed | `input.rs::pump`. Modifiers diff-synthesized as `Shift/Control/Alt/SuperLeft`. egui `Key` → `winit::KeyCode` mapping covers letters/digits/punctuation/F-keys/navigation. Mouse events only fire when the cursor is inside the framebuffer rect; F11 stays reserved for fullscreen. | +| Save state / Restore state | ✅ landed | `Machine::save_snapshot` (stops the CPU, snapshot to `saves//`, restart) + `Machine::ci_restore`. Errors surface as `Evt::Error` toasts. | +| Screenshot | ✅ landed | PNG-encoded from the latest `FrameSink` snapshot via the `png` crate, written off the GUI thread inside the worker. | +| Send IRIX halt | ✅ landed | TCP-connects to `127.0.0.1:8881` (iris's standing ttyd1 listener in non-CI mode) and writes `halt\n`. User waits for IRIX shutdown to complete, then clicks Stop. | +| Empty-media CD-ROM | ✅ landed (Task #11) | `ScsiDevice.backend: Option`, sense `0x3A` (MEDIUM NOT PRESENT) on `TEST UNIT READY` / `READ CAPACITY` / `READ` when no media. New `Wd33c93a::insert_disc(id, path)` and `eject_to_empty(id)`. SCSI menu gained **Attach empty CD-ROM drive**. | + +### Remaining polish (Phase C nice-to-haves) + +- **Live SCSI swap**: the SCSI menu currently edits the *config*, which + means attach / eject changes take effect at the next reset. + `Wd33c93a::insert_disc` and `eject_to_empty` are wired in the core — + exposing them through `Machine` and a runtime-only Cmd would let + inserts / ejects take effect on a live guest. +- **Live status accessors**: `Machine::is_in_prom()`, + `dirty_cow_sectors()`, and `subscribe_events() -> Receiver<…>` would + let the safe-stop dialog refine its current config-based heuristic + (warn whenever a read-write base-image disk is attached) into accurate + runtime corruption-risk reporting — e.g. clearing a read-write disk + that is idle or already halted. +- **`rfd` pickers everywhere**: a few remaining text-field path entries + in the Edit-config tabs don't have browse buttons yet. + +Each is independent and small enough to land on its own. diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml new file mode 100644 index 0000000..de2e33c --- /dev/null +++ b/iris-gui/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "iris-gui" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Group A (additive) features are always on for iris-gui so the user can +# enable them at runtime via the config UI: chd (.chd disk paths), camera +# (vino source), jit (IRIS_JIT toggle), rex-jit. They add code paths and +# native dependencies but no runtime cost when unused. +iris = { path = "..", features = ["chd", "camera", "jit", "rex-jit"] } +eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +egui = "0.29" +crossbeam-channel = "0.5" +parking_lot = "0.12" +# Match iris's winit version — Ps2Controller::push_kb takes +# winit::keyboard::KeyCode. +winit = "0.29" +png = "0.17" +rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +dirs = "5" +log = "0.4" +env_logger = "0.10" + +[[bin]] +name = "iris-gui" +path = "src/main.rs" diff --git a/iris-gui/assets/icon-original.png b/iris-gui/assets/icon-original.png new file mode 100644 index 0000000..e68e296 Binary files /dev/null and b/iris-gui/assets/icon-original.png differ diff --git a/iris-gui/assets/icons/hicolor/256x256/apps/iris-gui.png b/iris-gui/assets/icons/hicolor/256x256/apps/iris-gui.png new file mode 100644 index 0000000..7c8fff0 Binary files /dev/null and b/iris-gui/assets/icons/hicolor/256x256/apps/iris-gui.png differ diff --git a/iris-gui/assets/icons/icon-1024.png b/iris-gui/assets/icons/icon-1024.png new file mode 100644 index 0000000..f322106 Binary files /dev/null and b/iris-gui/assets/icons/icon-1024.png differ diff --git a/iris-gui/assets/icons/icon-128.png b/iris-gui/assets/icons/icon-128.png new file mode 100644 index 0000000..6c164d2 Binary files /dev/null and b/iris-gui/assets/icons/icon-128.png differ diff --git a/iris-gui/assets/icons/icon-16.png b/iris-gui/assets/icons/icon-16.png new file mode 100644 index 0000000..6ca6b5c Binary files /dev/null and b/iris-gui/assets/icons/icon-16.png differ diff --git a/iris-gui/assets/icons/icon-256.png b/iris-gui/assets/icons/icon-256.png new file mode 100644 index 0000000..7c8fff0 Binary files /dev/null and b/iris-gui/assets/icons/icon-256.png differ diff --git a/iris-gui/assets/icons/icon-32.png b/iris-gui/assets/icons/icon-32.png new file mode 100644 index 0000000..208b996 Binary files /dev/null and b/iris-gui/assets/icons/icon-32.png differ diff --git a/iris-gui/assets/icons/icon-48.png b/iris-gui/assets/icons/icon-48.png new file mode 100644 index 0000000..0c2f2fd Binary files /dev/null and b/iris-gui/assets/icons/icon-48.png differ diff --git a/iris-gui/assets/icons/icon-512.png b/iris-gui/assets/icons/icon-512.png new file mode 100644 index 0000000..d390cfa Binary files /dev/null and b/iris-gui/assets/icons/icon-512.png differ diff --git a/iris-gui/assets/icons/icon-64.png b/iris-gui/assets/icons/icon-64.png new file mode 100644 index 0000000..2779803 Binary files /dev/null and b/iris-gui/assets/icons/icon-64.png differ diff --git a/iris-gui/assets/icons/icon.icns b/iris-gui/assets/icons/icon.icns new file mode 100644 index 0000000..c98c2b3 Binary files /dev/null and b/iris-gui/assets/icons/icon.icns differ diff --git a/iris-gui/assets/icons/icon.ico b/iris-gui/assets/icons/icon.ico new file mode 100644 index 0000000..a11c688 Binary files /dev/null and b/iris-gui/assets/icons/icon.ico differ diff --git a/iris-gui/assets/icons/icon.png b/iris-gui/assets/icons/icon.png new file mode 100644 index 0000000..7c8fff0 Binary files /dev/null and b/iris-gui/assets/icons/icon.png differ diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs new file mode 100644 index 0000000..7dbb99b --- /dev/null +++ b/iris-gui/src/config_ui.rs @@ -0,0 +1,523 @@ +use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, Ui}; +use iris::build_features; +use std::path::Path; +use iris::config::{ + ForwardBind, ForwardProto, MachineConfig, NfsConfig, PortForwardConfig, + ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, +}; + +/// Which config tab is focused. Toolbar quick-buttons set this. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + General, + Disks, + Network, + Memory, + Display, + VideoIn, + Debug, + Ci, +} + +impl Tab { + pub const ALL: &'static [Tab] = &[ + Tab::General, Tab::Disks, Tab::Network, Tab::Memory, + Tab::Display, Tab::VideoIn, Tab::Debug, Tab::Ci, + ]; + pub fn label(self) -> &'static str { + match self { + Tab::General => "General", + Tab::Disks => "Disks", + Tab::Network => "Networking", + Tab::Memory => "Memory", + Tab::Display => "Display", + Tab::VideoIn => "Video-In", + Tab::Debug => "Debug / JIT", + Tab::Ci => "CI / Automation", + } + } +} + +/// IRIS_JIT* environment variables exposed as GUI fields. These get exported +/// into the process env before `Machine::new` is called (whether iris is +/// hosted in-process or spawned). All optional; empty means "leave default". +#[derive(Debug, Clone, Default)] +pub struct JitEnv { + pub iris_jit: bool, + pub max_tier: Option, + pub verify: bool, + pub no_stores: bool, + pub probe: String, + pub trace_file: String, + pub profile_file: String, + pub no_idle: bool, + pub debug_log: String, +} + +impl JitEnv { + /// Apply to current process env. Called by iris-gui before Machine::new. + pub fn export(&self) { + if self.iris_jit { std::env::set_var("IRIS_JIT", "1"); } + if let Some(t) = self.max_tier { std::env::set_var("IRIS_JIT_MAX_TIER", t.to_string()); } + if self.verify { std::env::set_var("IRIS_JIT_VERIFY", "1"); } + if self.no_stores { std::env::set_var("IRIS_JIT_NO_STORES", "1"); } + if !self.probe.is_empty() { std::env::set_var("IRIS_JIT_PROBE", &self.probe); } + if !self.trace_file.is_empty() { std::env::set_var("IRIS_JIT_TRACE", &self.trace_file); } + if !self.profile_file.is_empty() { std::env::set_var("IRIS_JIT_PROFILE", &self.profile_file); } + if self.no_idle { std::env::set_var("IRIS_NO_IDLE", "1"); } + if !self.debug_log.is_empty() { std::env::set_var("IRIS_DEBUG_LOG", &self.debug_log); } + } +} + +pub fn show_tab(ui: &mut Ui, tab: Tab, cfg: &mut MachineConfig, jit: &mut JitEnv) { + ScrollArea::vertical().show(ui, |ui| match tab { + Tab::General => show_general(ui, cfg), + Tab::Disks => show_disks(ui, cfg), + Tab::Network => show_network(ui, cfg), + Tab::Memory => show_memory(ui, cfg), + Tab::Display => show_display(ui, cfg), + Tab::VideoIn => show_vino(ui, cfg), + Tab::Debug => show_debug(ui, cfg, jit), + Tab::Ci => show_ci(ui, cfg), + }); +} + +fn show_general(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("General"); + Grid::new("general_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("PROM image"); + path_row(ui, "prom", &mut cfg.prom, Pick::OpenFile, PROM_FILTERS); + ui.end_row(); + + ui.label("NVRAM file"); + path_row(ui, "nvram", &mut cfg.nvram, Pick::SaveFile, NVRAM_FILTERS); + ui.end_row(); + + ui.label("Serial log (ttyd1 → file)"); + path_row_opt(ui, "serial_log", &mut cfg.serial_log, Pick::SaveFile, ANY_FILTERS); + ui.end_row(); + }); +} + +fn show_memory(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("Memory"); + ui.label("RAM bank sizes in MB (valid: 0, 8, 16, 32, 64, 128)"); + Grid::new("mem_grid").num_columns(2).striped(true).show(ui, |ui| { + for i in 0..4 { + ui.label(format!("Bank {i}")); + let cur = cfg.banks[i]; + ComboBox::from_id_salt(("bank", i)).selected_text(format!("{cur} MB")) + .show_ui(ui, |ui| { + for &sz in VALID_BANK_SIZES { + ui.selectable_value(&mut cfg.banks[i], sz, format!("{sz} MB")); + } + }); + ui.end_row(); + } + }); + let total: u32 = cfg.banks.iter().sum(); + ui.label(format!("Total: {total} MB")); +} + +fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("Display"); + Grid::new("disp_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Window scale"); + ComboBox::from_id_salt("scale").selected_text(format!("{}×", cfg.scale)) + .show_ui(ui, |ui| { + for s in 1u32..=4 { + ui.selectable_value(&mut cfg.scale, s, format!("{s}×")); + } + }); + ui.end_row(); + + ui.label("Headless (no REX3 graphics)"); + ui.checkbox(&mut cfg.headless, ""); + ui.end_row(); + + ui.label("No audio (disable HAL2)"); + ui.checkbox(&mut cfg.no_audio, ""); + ui.end_row(); + }); +} + +fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("SCSI devices"); + ui.horizontal(|ui| { + ui.label("IDs 1–7. CD-ROMs typically use 4–6."); + if build_features::CHD { + ui.label(RichText::new("[CHD support: ON]").color(Color32::LIGHT_GREEN).small()); + } else { + ui.label(RichText::new("[CHD support: OFF — rebuild with --features chd]") + .color(Color32::from_rgb(220, 170, 90)).small()); + } + }); + let mut to_delete: Option = None; + for id in 1u8..=7 { + ui.separator(); + let exists = cfg.scsi.contains_key(&id); + ui.horizontal(|ui| { + ui.strong(format!("scsi{id}")); + if exists { + if ui.button("Remove").clicked() { + to_delete = Some(id); + } + } else if ui.button("Attach…").clicked() { + cfg.scsi.insert(id, ScsiDeviceConfig { + path: format!("scsi{id}.raw"), + discs: vec![], + cdrom: false, + overlay: false, + scratch: false, + size_mb: None, + }); + } + }); + if let Some(dev) = cfg.scsi.get_mut(&id) { + Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| { + ui.label("Image path"); + path_row(ui, ("scsi_path", id), &mut dev.path, + if dev.scratch { Pick::SaveFile } else { Pick::OpenFile }, + DISK_FILTERS); + ui.end_row(); + if dev.path.ends_with(".chd") && !build_features::CHD { + ui.label(""); + ui.label(RichText::new("⚠ .chd path but this build lacks CHD support — rebuild with --features chd") + .color(Color32::from_rgb(230, 140, 70))); + ui.end_row(); + } + + ui.label("Type"); + let was_cd = dev.cdrom; + let mut is_cd = dev.cdrom; + ComboBox::from_id_salt(("type", id)) + .selected_text(if is_cd { "CD-ROM" } else { "HDD" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut is_cd, false, "HDD"); + ui.selectable_value(&mut is_cd, true, "CD-ROM"); + }); + // Switching to CD-ROM defaults to an empty drive (no media): + // clear the auto-generated HDD placeholder path so it doesn't + // look like a (missing) disc. Load media via "Insert disc…" in + // the SCSI menu, or just type a path here. + if is_cd && !was_cd && dev.path == format!("scsi{id}.raw") { + dev.path.clear(); + } + dev.cdrom = is_cd; + ui.end_row(); + if dev.cdrom && dev.path.is_empty() { + ui.label(""); + ui.label(RichText::new("empty drive (no media) — insert a disc via the SCSI menu") + .weak().small()); + ui.end_row(); + } + + ui.label("Overlay (COW writes → .overlay)"); + ui.checkbox(&mut dev.overlay, ""); + ui.end_row(); + + ui.label("Scratch volume"); + ui.checkbox(&mut dev.scratch, ""); + ui.end_row(); + + if dev.scratch { + ui.label("Scratch size (MB)"); + let mut sz = dev.size_mb.unwrap_or(64); + if ui.add(DragValue::new(&mut sz).range(1..=8192)).changed() { + dev.size_mb = Some(sz); + } + ui.end_row(); + } + }); + + if dev.cdrom { + ui.label("Extra changer discs:"); + let mut drop_idx: Option = None; + for (i, disc) in dev.discs.iter_mut().enumerate() { + ui.horizontal(|ui| { + path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS); + if ui.button("×").clicked() { drop_idx = Some(i); } + }); + } + if let Some(i) = drop_idx { dev.discs.remove(i); } + if ui.button("+ Add disc").clicked() { + dev.discs.push(String::new()); + } + } + } + } + if let Some(id) = to_delete { cfg.scsi.remove(&id); } +} + +fn show_network(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("Networking"); + Grid::new("nat_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("NAT subnet (CIDR)"); + let mut s = cfg.nat_subnet.clone().unwrap_or_default(); + if ui.add(TextEdit::singleline(&mut s).hint_text("192.168.0.0/24").desired_width(220.0)).changed() { + cfg.nat_subnet = if s.is_empty() { None } else { Some(s) }; + } + ui.end_row(); + }); + + ui.separator(); + ui.strong("Port forwards"); + let mut drop: Option = None; + for (i, pf) in cfg.port_forward.iter_mut().enumerate() { + ui.horizontal(|ui| { + ComboBox::from_id_salt(("proto", i)) + .selected_text(match pf.proto { ForwardProto::Tcp => "tcp", ForwardProto::Udp => "udp" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut pf.proto, ForwardProto::Tcp, "tcp"); + ui.selectable_value(&mut pf.proto, ForwardProto::Udp, "udp"); + }); + ui.label("host"); + ui.add(DragValue::new(&mut pf.host_port).range(1..=65535)); + ui.label("→ guest"); + ui.add(DragValue::new(&mut pf.guest_port).range(1..=65535)); + ComboBox::from_id_salt(("bind", i)) + .selected_text(match pf.bind { ForwardBind::Localhost => "localhost", ForwardBind::Any => "any" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut pf.bind, ForwardBind::Localhost, "localhost"); + ui.selectable_value(&mut pf.bind, ForwardBind::Any, "any"); + }); + if ui.button("×").clicked() { drop = Some(i); } + }); + } + if let Some(i) = drop { cfg.port_forward.remove(i); } + if ui.button("+ Add forward").clicked() { + cfg.port_forward.push(PortForwardConfig { + proto: ForwardProto::Tcp, host_port: 0, guest_port: 0, bind: ForwardBind::Localhost, + }); + } + + ui.separator(); + ui.strong("NFS share"); + let mut has_nfs = cfg.nfs.is_some(); + if ui.checkbox(&mut has_nfs, "Enable NFS").changed() { + cfg.nfs = if has_nfs { + Some(NfsConfig { + shared_dir: String::new(), + unfsd: "unfsd".into(), + nfs_host_port: 12049, + mountd_host_port: 11234, + }) + } else { None }; + } + if let Some(nfs) = cfg.nfs.as_mut() { + Grid::new("nfs_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Shared dir"); + path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS); + ui.end_row(); + ui.label("unfsd binary"); + path_row(ui, "nfs_unfsd", &mut nfs.unfsd, Pick::OpenFile, ANY_FILTERS); + ui.end_row(); + ui.label("NFS host port"); + ui.add(DragValue::new(&mut nfs.nfs_host_port).range(1..=65535)); + ui.end_row(); + ui.label("mountd host port"); + ui.add(DragValue::new(&mut nfs.mountd_host_port).range(1..=65535)); + ui.end_row(); + }); + } +} + +fn show_vino(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("Video-In (IndyCam)"); + Grid::new("vino_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Source"); + ComboBox::from_id_salt("vino_src") + .selected_text(match cfg.vino.source { + VinoSource::Camera => "camera", + VinoSource::TestPattern => "test_pattern", + VinoSource::Black => "black", + VinoSource::Off => "off (disabled)", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cfg.vino.source, VinoSource::Off, "off (disabled)"); + ui.selectable_value(&mut cfg.vino.source, VinoSource::TestPattern, "test_pattern"); + let camera_label = if build_features::CAMERA { + "camera" + } else { + "camera (needs --features camera)" + }; + ui.selectable_value(&mut cfg.vino.source, VinoSource::Camera, camera_label); + ui.selectable_value(&mut cfg.vino.source, VinoSource::Black, "black"); + }); + ui.end_row(); + + ui.label("Standard"); + ComboBox::from_id_salt("vino_std") + .selected_text(match cfg.vino.standard { VinoStandard::Ntsc => "ntsc", VinoStandard::Pal => "pal" }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cfg.vino.standard, VinoStandard::Ntsc, "ntsc"); + ui.selectable_value(&mut cfg.vino.standard, VinoStandard::Pal, "pal"); + }); + ui.end_row(); + + ui.label("Camera index"); + ui.add(DragValue::new(&mut cfg.vino.camera_index).range(0..=15)); + ui.end_row(); + }); +} + +fn show_debug(ui: &mut Ui, cfg: &mut MachineConfig, jit: &mut JitEnv) { + ui.heading("Debug / JIT"); + if build_features::LIGHTNING { + ui.label(RichText::new( + "⚡ Lightning build — interactive debugging is disabled \ + (no breakpoints, no GDB stub, no traceback). Rebuild without \ + --features lightning to re-enable.").color(Color32::from_rgb(220, 170, 90))); + ui.separator(); + } else { + Grid::new("dbg_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("GDB stub port"); + let mut port = cfg.gdb_port.unwrap_or(0); + if ui.add(DragValue::new(&mut port).range(0..=65535)).changed() { + cfg.gdb_port = if port == 0 { None } else { Some(port) }; + } + ui.end_row(); + }); + } + ui.separator(); + ui.label("JIT (requires `cargo build --features jit`)"); + Grid::new("jit_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Enable JIT (IRIS_JIT=1)"); + ui.checkbox(&mut jit.iris_jit, ""); + ui.end_row(); + + ui.label("Max tier (0=ALU, 1=Loads, 2=Full)"); + let mut t = jit.max_tier.unwrap_or(2); + if ui.add(DragValue::new(&mut t).range(0..=2)).changed() { + jit.max_tier = Some(t); + } + ui.end_row(); + + ui.label("Verify against interpreter"); + ui.checkbox(&mut jit.verify, ""); + ui.end_row(); + + ui.label("Disable JIT stores (diagnostic)"); + ui.checkbox(&mut jit.no_stores, ""); + ui.end_row(); + + ui.label("Probe interval"); + ui.add(TextEdit::singleline(&mut jit.probe).hint_text("default 200").desired_width(120.0)); + ui.end_row(); + + ui.label("Trace file"); + path_row(ui, "jit_trace", &mut jit.trace_file, Pick::SaveFile, ANY_FILTERS); + ui.end_row(); + + ui.label("Profile file"); + path_row(ui, "jit_profile", &mut jit.profile_file, Pick::SaveFile, ANY_FILTERS); + ui.end_row(); + }); + ui.separator(); + Grid::new("misc_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Disable idle park (IRIS_NO_IDLE)"); + ui.checkbox(&mut jit.no_idle, ""); + ui.end_row(); + ui.label("Devlog spec (IRIS_DEBUG_LOG)"); + ui.add(TextEdit::singleline(&mut jit.debug_log).hint_text("all, or e.g. mc,mips").desired_width(280.0)); + ui.end_row(); + }); +} + +fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) { + ui.heading("CI / Automation"); + Grid::new("ci_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Enable CI mode"); + ui.checkbox(&mut cfg.ci, ""); + ui.end_row(); + ui.label("CI socket path"); + path_row(ui, "ci_socket", &mut cfg.ci_socket, Pick::SaveFile, SOCKET_FILTERS); + ui.end_row(); + ui.label("Keep window visible (--ci-display)"); + ui.checkbox(&mut cfg.ci_display, ""); + ui.end_row(); + }); +} + +/// Serialize `cfg` back to TOML string in the same style as iris.toml. +pub fn cfg_to_toml(cfg: &MachineConfig) -> Result { + toml::to_string_pretty(cfg).map_err(|e| e.to_string()) +} + +/// How a Browse button should pick a path. +#[derive(Clone, Copy)] +enum Pick { + OpenFile, + SaveFile, + Dir, +} + +/// A TextEdit + 📁 Browse button that updates `value` in place. +/// `filters` is a list of (label, &[extensions]); ignored for `Pick::Dir`. +fn path_row( + ui: &mut Ui, + id: impl std::hash::Hash, + value: &mut String, + mode: Pick, + filters: &[(&str, &[&str])], +) { + ui.push_id(id, |ui| { + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(value).desired_width(320.0)); + if ui.button("📁").on_hover_text("Browse…").clicked() { + let mut d = rfd::FileDialog::new(); + // Start the dialog in the existing path's directory if any. + if !value.is_empty() { + let p = Path::new(value); + if let Some(parent) = p.parent() { + if parent.as_os_str().len() > 0 && parent.exists() { + d = d.set_directory(parent); + } + } + if let Some(name) = p.file_name() { + d = d.set_file_name(name.to_string_lossy()); + } + } + if matches!(mode, Pick::OpenFile | Pick::SaveFile) { + for (label, exts) in filters { + d = d.add_filter(*label, exts); + } + } + let picked = match mode { + Pick::OpenFile => d.pick_file(), + Pick::SaveFile => d.save_file(), + Pick::Dir => d.pick_folder(), + }; + if let Some(p) = picked { + *value = p.to_string_lossy().into_owned(); + } + } + }); + }); +} + +/// Same as `path_row` but for `Option` — Browse populates Some, +/// the user can clear by emptying the text. +fn path_row_opt( + ui: &mut Ui, + id: impl std::hash::Hash, + value: &mut Option, + mode: Pick, + filters: &[(&str, &[&str])], +) { + let mut s = value.clone().unwrap_or_default(); + path_row(ui, id, &mut s, mode, filters); + *value = if s.is_empty() { None } else { Some(s) }; +} + +/// Common file filters. +const PROM_FILTERS: &[(&str, &[&str])] = &[("PROM image", &["bin"]), ("All", &["*"])]; +const NVRAM_FILTERS: &[(&str, &[&str])] = &[("NVRAM", &["bin"]), ("All", &["*"])]; +const DISK_FILTERS: &[(&str, &[&str])] = &[ + ("Disk images", &["raw", "img", "chd"]), + ("ISO images", &["iso"]), + ("All", &["*"]), +]; +const ANY_FILTERS: &[(&str, &[&str])] = &[("All", &["*"])]; +const SOCKET_FILTERS: &[(&str, &[&str])] = &[("Unix socket", &["sock"]), ("All", &["*"])]; + diff --git a/iris-gui/src/dialogs.rs b/iris-gui/src/dialogs.rs new file mode 100644 index 0000000..3a42456 --- /dev/null +++ b/iris-gui/src/dialogs.rs @@ -0,0 +1,2 @@ +pub mod new_machine; +pub mod create_disk; diff --git a/iris-gui/src/dialogs/create_disk.rs b/iris-gui/src/dialogs/create_disk.rs new file mode 100644 index 0000000..0f02c53 --- /dev/null +++ b/iris-gui/src/dialogs/create_disk.rs @@ -0,0 +1,93 @@ +use eframe::egui::{self, Color32, Grid, RichText, Slider, TextEdit}; +use std::path::PathBuf; + +/// Modal that creates a blank zero-filled disk image for a chosen SCSI ID. +/// Mirrors snow's DiskImageDialog. +pub struct CreateDiskDialog { + open: bool, + scsi_id: u8, + filename: String, + size_mb: f64, + result: Option, +} + +pub struct CreateDiskResult { + pub scsi_id: u8, + pub path: PathBuf, +} + +impl Default for CreateDiskDialog { + fn default() -> Self { + Self { open: false, scsi_id: 1, filename: String::new(), size_mb: 1024.0, result: None } + } +} + +impl CreateDiskDialog { + pub fn open_for(&mut self, scsi_id: u8) { + self.scsi_id = scsi_id; + self.filename = format!("scsi{scsi_id}.raw"); + self.size_mb = 1024.0; + self.result = None; + self.open = true; + } + pub fn take_result(&mut self) -> Option { self.result.take() } + + pub fn show(&mut self, ctx: &egui::Context) { + if !self.open { return; } + let mut close = false; + egui::Window::new(format!("Create blank HDD image for SCSI #{}", self.scsi_id)) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(380.0); + Grid::new("create_disk_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Filename"); + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(&mut self.filename).desired_width(220.0)); + if ui.button("📁").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("Disk image", &["raw", "img"]) + .set_file_name(&self.filename) + .save_file() + { + self.filename = p.to_string_lossy().into_owned(); + } + } + }); + ui.end_row(); + ui.label("Size (MB)"); + ui.add(Slider::new(&mut self.size_mb, 8.0..=16384.0).step_by(8.0).logarithmic(true)); + ui.end_row(); + }); + ui.add_space(4.0); + ui.label(RichText::new("The image will be created as a zero-filled file. \ + Reset the machine after attaching new drives.") + .color(Color32::GRAY).small()); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { close = true; } + if ui.add(egui::Button::new("Create") + .fill(Color32::from_rgb(60, 110, 60))).clicked() + { + // Create file on disk now. + let path = PathBuf::from(&self.filename); + let size_bytes = (self.size_mb * 1024.0 * 1024.0) as u64; + match std::fs::File::create(&path) + .and_then(|f| f.set_len(size_bytes)) + { + Ok(_) => { + self.result = Some(CreateDiskResult { scsi_id: self.scsi_id, path }); + close = true; + } + Err(e) => { + // Show an inline error; keep dialog open. + log::error!("create disk image failed: {e}"); + } + } + } + }); + }); + if close { self.open = false; } + } +} diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs new file mode 100644 index 0000000..0462f54 --- /dev/null +++ b/iris-gui/src/dialogs/new_machine.rs @@ -0,0 +1,241 @@ +use eframe::egui::{self, Color32, ComboBox, Grid, RichText, TextEdit}; +use iris::config::{MachineConfig, ScsiDeviceConfig, VALID_BANK_SIZES}; + +/// "New machine" startup dialog — analogous to snow's ModelSelectionDialog. +/// Pops up at first run (or on `File → New machine…`) to bootstrap a config. +pub struct NewMachineDialog { + open: bool, + pub name: String, + pub prom_path: String, + pub use_embedded_prom: bool, + pub nvram_path: String, + pub ram_total_mb: u32, + /// If true the dialog exposes 4 per-bank selectors and ignores ram_total_mb. + pub ram_advanced: bool, + pub ram_banks: [u32; 4], + pub scsi1_path: String, + pub create_blank_scsi1: bool, + pub cdrom4_path: String, + pub attach_cdrom: bool, + result: Option, +} + +pub struct NewMachineResult { + pub name: String, + pub cfg: MachineConfig, +} + +impl Default for NewMachineDialog { + fn default() -> Self { + Self { + open: false, + name: "indy".into(), + prom_path: "prom.bin".into(), + use_embedded_prom: true, + nvram_path: "nvram.bin".into(), + ram_total_mb: 256, + ram_advanced: false, + ram_banks: [128, 128, 0, 0], + scsi1_path: "scsi1.raw".into(), + create_blank_scsi1: false, + cdrom4_path: String::new(), + attach_cdrom: false, + result: None, + } + } +} + +const RAM_PRESETS: &[u32] = &[32, 64, 96, 128, 192, 256]; + +pub fn distribute_ram(total: u32) -> [u32; 4] { + // Greedy fill banks 0..3 with the largest valid bank size that fits. + let mut remaining = total; + let mut banks = [0u32; 4]; + for slot in &mut banks { + // Pick the largest size in VALID_BANK_SIZES that is <= remaining. + let pick = VALID_BANK_SIZES.iter().filter(|&&s| s > 0 && s <= remaining).max().copied().unwrap_or(0); + *slot = pick; + remaining -= pick; + if remaining == 0 { break; } + } + banks +} + +impl NewMachineDialog { + pub fn open(&mut self) { self.open = true; self.result = None; } + pub fn take_result(&mut self) -> Option { self.result.take() } + + pub fn show(&mut self, ctx: &egui::Context) { + if !self.open { return; } + let mut close = false; + egui::Window::new("New machine") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(440.0); + ui.label(RichText::new("Configure a new SGI Indy emulation").strong()); + ui.add_space(4.0); + + Grid::new("new_machine_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Name"); + ui.add(TextEdit::singleline(&mut self.name).desired_width(200.0)); + ui.end_row(); + ui.label("PROM image"); + ui.horizontal(|ui| { + ui.add_enabled(!self.use_embedded_prom, + TextEdit::singleline(&mut self.prom_path).desired_width(260.0)); + if ui.add_enabled(!self.use_embedded_prom, egui::Button::new("📁")).clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("PROM image", &["bin"]) + .pick_file() + { + self.prom_path = p.to_string_lossy().into_owned(); + } + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut self.use_embedded_prom, "Use embedded PROM (bundled with iris)"); + ui.end_row(); + + ui.label("NVRAM file"); + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(&mut self.nvram_path).desired_width(260.0)); + if ui.button("📁").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("NVRAM", &["bin"]).save_file() + { + self.nvram_path = p.to_string_lossy().into_owned(); + } + } + }); + ui.end_row(); + + if !self.ram_advanced { + ui.label("Total RAM"); + ComboBox::from_id_salt("ram_total") + .selected_text(format!("{} MB", self.ram_total_mb)) + .show_ui(ui, |ui| { + for &s in RAM_PRESETS { + ui.selectable_value(&mut self.ram_total_mb, s, format!("{s} MB")); + } + }); + ui.end_row(); + } else { + for i in 0..4 { + ui.label(format!("Bank {i}")); + ComboBox::from_id_salt(("nm_bank", i)) + .selected_text(format!("{} MB", self.ram_banks[i])) + .show_ui(ui, |ui| { + for &sz in VALID_BANK_SIZES { + ui.selectable_value(&mut self.ram_banks[i], sz, format!("{sz} MB")); + } + }); + ui.end_row(); + } + let total: u32 = self.ram_banks.iter().sum(); + ui.label("Total"); + ui.label(format!("{total} MB")); + ui.end_row(); + } + ui.label(""); + ui.checkbox(&mut self.ram_advanced, "Advanced: configure individual banks"); + ui.end_row(); + }); + + ui.separator(); + ui.label(RichText::new("Boot disk (optional)").strong()); + Grid::new("new_machine_disk").num_columns(2).striped(true).show(ui, |ui| { + ui.label("SCSI ID 1 (HDD)"); + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(&mut self.scsi1_path).desired_width(260.0)); + if ui.button("📁").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("Disk image", &["raw", "img", "chd"]) + .pick_file() + { + self.scsi1_path = p.to_string_lossy().into_owned(); + self.create_blank_scsi1 = false; + } + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut self.create_blank_scsi1, + "If the file doesn't exist, treat as empty (suitable for fresh IRIX install)"); + ui.end_row(); + + ui.label("SCSI ID 4 (CD-ROM)"); + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(&mut self.cdrom4_path).desired_width(260.0)); + if ui.button("📁").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("ISO", &["iso"]) + .pick_file() + { + self.cdrom4_path = p.to_string_lossy().into_owned(); + self.attach_cdrom = true; + } + } + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut self.attach_cdrom, "Attach an install CD-ROM at SCSI ID 4"); + ui.end_row(); + }); + + ui.separator(); + ui.label(RichText::new( + "You can refine networking, JIT, video-in and CI settings from the menus after creation." + ).color(Color32::GRAY).small()); + + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { close = true; } + if ui.add(egui::Button::new(RichText::new("Create").strong()) + .fill(Color32::from_rgb(60, 110, 60))).clicked() + { + let mut cfg = MachineConfig::default(); + cfg.prom = if self.use_embedded_prom { String::new() } else { self.prom_path.clone() }; + // Empty prom path makes Machine::new fall back to embedded + // (the load path warns + falls back when the file is missing). + if cfg.prom.is_empty() { cfg.prom = "(embedded)".into(); } + cfg.nvram = self.nvram_path.clone(); + cfg.banks = if self.ram_advanced { + self.ram_banks + } else { + distribute_ram(self.ram_total_mb) + }; + // SCSI defaults: drop the built-in entries unless the + // user explicitly opted in. + cfg.scsi.clear(); + if !self.scsi1_path.is_empty() { + cfg.scsi.insert(1, ScsiDeviceConfig { + path: self.scsi1_path.clone(), + discs: vec![], + cdrom: false, + overlay: false, + scratch: false, + size_mb: None, + }); + } + if self.attach_cdrom && !self.cdrom4_path.is_empty() { + cfg.scsi.insert(4, ScsiDeviceConfig { + path: self.cdrom4_path.clone(), + discs: vec![], + cdrom: true, + overlay: false, + scratch: false, + size_mb: None, + }); + } + let name = if self.name.trim().is_empty() { "indy".to_string() } else { self.name.trim().to_string() }; + self.result = Some(NewMachineResult { name, cfg }); + close = true; + } + }); + }); + if close { self.open = false; } + } +} diff --git a/iris-gui/src/framebuffer.rs b/iris-gui/src/framebuffer.rs new file mode 100644 index 0000000..fe06aca --- /dev/null +++ b/iris-gui/src/framebuffer.rs @@ -0,0 +1,107 @@ +//! Headless renderer that captures REX3's framebuffer into a shared buffer +//! for egui to upload as a texture each frame. + +use iris::rex3::Renderer; +use parking_lot::{Mutex, MutexGuard}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// One captured frame: tightly-packed RGBA bytes plus pixel dimensions. +#[derive(Clone, Default)] +pub struct Frame { + pub width: usize, + pub height: usize, + /// Length is width * height * 4. Pixel order: R, G, B, A. + pub rgba: Vec, + /// Bumped every time `Renderer::render` lands a new buffer; egui uses + /// this to skip the texture upload when nothing has changed. + pub seq: u64, +} + +/// Shared latest-frame slot. The renderer writes; the GUI reads. +/// +/// `seq` is mirrored into a lock-free atomic so the GUI can ask "is there a +/// new frame?" on every repaint without taking the mutex or cloning the +/// multi-MB buffer. It only locks + clones when the sequence actually moved. +#[derive(Default, Clone)] +pub struct FrameSink { + frame: Arc>, + seq: Arc, +} + +impl FrameSink { + pub fn new() -> Self { Self::default() } + + /// Lock-free latest sequence number (0 = no frame produced yet). + pub fn seq(&self) -> u64 { self.seq.load(Ordering::Acquire) } + + /// Clone the latest frame out. Callers should gate this on `seq()` having + /// changed so they don't copy the whole buffer when nothing is new. + pub fn snapshot(&self) -> Frame { self.frame.lock().clone() } + + fn lock(&self) -> MutexGuard<'_, Frame> { self.frame.lock() } +} + +/// `iris::rex3::Renderer` implementation that copies each `render` call +/// into a `FrameSink`. The REX3 refresh thread invokes us at video rate; +/// we keep the work minimal (one pass) so we don't stall it. +pub struct CaptureRenderer { + sink: FrameSink, + seq: u64, +} + +impl CaptureRenderer { + pub fn new(sink: FrameSink) -> Self { + Self { sink, seq: 0 } + } +} + +impl Renderer for CaptureRenderer { + fn render(&mut self, buffer: &[u32], width: usize, height: usize) { + // The REX3 framebuffer has row stride 2048 in u32 words regardless + // of `width` — typical mode is 1280×1024 displayable. + const STRIDE: usize = 2048; + if width == 0 || height == 0 { return; } + let needed = width.checked_mul(height).and_then(|n| n.checked_mul(4)).unwrap_or(0); + if needed == 0 { return; } + + let mut frame = self.sink.lock(); + if frame.rgba.len() != needed { + frame.rgba = vec![0u8; needed]; + } + frame.width = width; + frame.height = height; + + // REX3 pixel layout is RGBA in u32 little-endian: R is the low byte, + // matching what egui's `ColorImage::from_rgba_unmultiplied` expects. + // The high byte is NOT opacity — REX3 packs dither/overlay bits there + // (e.g. the Bayer index) — but egui composites textures with alpha + // blending, so we must force alpha to 0xFF or the frame renders + // (near-)transparent (black). Do the pack-and-opaque in a single pass + // per row: `word | 0xFF00_0000` writes R,G,B verbatim and A = 0xFF. + for y in 0..height { + let src_row_start = y * STRIDE; + let src_row_end = src_row_start + width; + if src_row_end > buffer.len() { break; } + let src_row = &buffer[src_row_start..src_row_end]; + let dst_row_start = y * width * 4; + let dst_row_end = dst_row_start + width * 4; + let dst_row = &mut frame.rgba[dst_row_start..dst_row_end]; + + for (dst_px, &word) in dst_row.chunks_exact_mut(4).zip(src_row) { + dst_px.copy_from_slice(&(word | 0xFF00_0000).to_le_bytes()); + } + } + + self.seq = self.seq.wrapping_add(1); + frame.seq = self.seq; + drop(frame); + // Publish the new sequence after the buffer write is visible. + self.sink.seq.store(self.seq, Ordering::Release); + } + + fn resize(&mut self, _width: usize, _height: usize) { + // No-op: we resize the destination buffer in `render` based on the + // actual `width × height` of the next frame. + } +} diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs new file mode 100644 index 0000000..5313edc --- /dev/null +++ b/iris-gui/src/handle.rs @@ -0,0 +1,347 @@ +use crate::framebuffer::{CaptureRenderer, FrameSink}; +use crossbeam_channel::{unbounded, Receiver, Sender}; +use iris::config::MachineConfig; +use iris::machine::Machine; +use iris::ps2::Ps2Controller; +use parking_lot::Mutex; +use std::path::PathBuf; +use std::sync::Arc; +use std::thread::JoinHandle; + +#[derive(Debug)] +pub enum Cmd { + Start(Box), + Stop, + SaveState(String), + RestoreState(String), + Screenshot(PathBuf), + Quit, +} + +// PowerOff is emitted when iris exposes `Machine::subscribe_events` (still +// pending). The rest are emitted by the worker on the relevant Cmd success +// path; Status is emitted on a periodic tick while a machine is running. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum Evt { + Started, + Stopped, + PowerOff, + StateSaved(String), + StateRestored(String), + Screenshot(PathBuf), + Error(String), + Status(Status), +} + +#[derive(Debug, Clone, Default)] +pub struct Status { + pub running: bool, + /// CPU is currently in PROM (not yet booted IRIX, or post-halt). + pub in_prom: bool, + /// IRIX has shut down cleanly (PowerOff event observed). + pub power_off_seen: bool, + /// Count of dirty COW overlay sectors across all SCSI devices. + pub dirty_cow: usize, + /// Approximate instructions/sec (millions). + pub mips: f32, +} + +pub struct EmulatorHandle { + cmd_tx: Sender, + evt_rx: Receiver, + thread: Option>, + /// Shared latest-framebuffer slot, written by the CaptureRenderer + /// inside the worker and read by the GUI each egui frame. + pub frame_sink: FrameSink, + /// Handle to the live machine's PS/2 controller (when running), so + /// the GUI thread can push keyboard / mouse events at it directly. + /// `None` when no machine is up. + pub ps2: Arc>>>, + pub status: Status, +} + +impl EmulatorHandle { + pub fn spawn() -> Self { + let (cmd_tx, cmd_rx) = unbounded::(); + let (evt_tx, evt_rx) = unbounded::(); + let frame_sink = FrameSink::new(); + let sink_for_worker = frame_sink.clone(); + let ps2: Arc>>> = Arc::new(Mutex::new(None)); + let ps2_for_worker = ps2.clone(); + let thread = std::thread::Builder::new() + .name("iris-gui-emu".into()) + // Machine::new alone puts >1 MB on the stack (Physical::device_map), + // and unlike the CLI — which builds the machine on a minimal, + // dedicated thread — we call it from inside worker_loop's deeper + // frame (catch_unwind + loop). With unoptimized debug-sized frames + // the 8 MB the CLI uses overflows during Rex3::new, so give the + // worker generous headroom. This is virtual address space, lazily + // committed, so the large reservation has no real cost. + .stack_size(64 * 1024 * 1024) + .spawn(move || worker_loop(cmd_rx, evt_tx, sink_for_worker, ps2_for_worker)) + .expect("spawn iris-gui-emu thread"); + Self { + cmd_tx, + evt_rx, + thread: Some(thread), + frame_sink, + ps2, + status: Status::default(), + } + } + + pub fn send(&self, cmd: Cmd) { + let _ = self.cmd_tx.send(cmd); + } + + /// Drain pending events; return them for the UI to consume. + pub fn drain_events(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(evt) = self.evt_rx.try_recv() { + if let Evt::Status(s) = &evt { + // The worker only knows the perf-derived fields; `running`, + // `power_off_seen` and `in_prom` are driven by lifecycle + // events, so merge rather than replace to avoid clobbering them. + self.status.mips = s.mips; + self.status.dirty_cow = s.dirty_cow; + } + match &evt { + Evt::Started => self.status.running = true, + Evt::Stopped => self.status.running = false, + Evt::PowerOff => self.status.power_off_seen = true, + _ => {} + } + out.push(evt); + } + out + } + + pub fn is_running(&self) -> bool { self.status.running } + + /// Stop the machine (if running) and join the worker thread. Idempotent. + /// Call this from the GUI's `on_exit` so a running machine is cleaned up + /// even when the user closes the window without pressing Stop — and so the + /// cleanup completes synchronously rather than racing process teardown. + /// The worker's Quit handler bounds the stop with a timeout, so this can't + /// hang on a wedged guest. + pub fn shutdown(&mut self) { + let _ = self.cmd_tx.send(Cmd::Quit); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +impl Drop for EmulatorHandle { + // Backstop in case `shutdown()` wasn't called explicitly (e.g. a panic + // path). No-op once the worker has already been joined. + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Worker thread loop. Owns the `Machine` while it exists. The eframe app +/// thread sends `Cmd`s and drains `Evt`s, never touching the machine +/// directly. All `Machine` calls are wrapped in `catch_unwind` so a panic +/// becomes an `Evt::Error` toast rather than killing the worker. +fn worker_loop( + cmd_rx: Receiver, + evt_tx: Sender, + frame_sink: FrameSink, + ps2_slot: Arc>>>, +) { + let mut machine: Option> = None; + // Live MIPS estimate: read REX3's free-running cycle counter and divide + // the delta by wall-clock between ticks. Mirrors the status-bar math in + // src/disp.rs, but driven here since the GUI never runs REX3's own + // refresh/status-bar loop. `None` until a machine is up. + let mut cycles: Option> = None; + let mut prev_cycles: u64 = 0; + let mut prev_tick = std::time::Instant::now(); + // Tick cadence for the status poll while idle on the command channel. + const STATUS_TICK: std::time::Duration = std::time::Duration::from_millis(500); + loop { + match cmd_rx.recv_timeout(STATUS_TICK) { + // Periodic tick (no command pending): refresh the MIPS estimate. + Err(crossbeam_channel::RecvTimeoutError::Timeout) => { + if let Some(c) = &cycles { + let now = std::time::Instant::now(); + let dt = now.duration_since(prev_tick).as_secs_f64(); + if dt >= 0.1 { + let cur = c.load(std::sync::atomic::Ordering::Relaxed); + let dc = cur.wrapping_sub(prev_cycles); + let mips = (dc as f64 / dt / 1_000_000.0 * 10.0).round() as f32 / 10.0; + prev_cycles = cur; + prev_tick = now; + let _ = evt_tx.send(Evt::Status(Status { mips, ..Status::default() })); + } + } + continue; + } + Ok(Cmd::Start(cfg)) => { + if machine.is_some() { + let _ = evt_tx.send(Evt::Error("emulator already running".into())); + continue; + } + // Wrap construction in catch_unwind: Machine::new and + // friends may panic on missing files, bad images, etc. + // We surface those as Evt::Error toasts instead of + // silently killing the worker thread. + // + // We do NOT force `headless = true` here — iris-gui needs + // REX3 alive so it can capture the framebuffer. Iris + // itself never opens a winit window unless `main.rs` + // calls `Ui::run`; we don't, so there's no event-loop + // conflict with eframe. + let cfg_owned = *cfg; + let sink_for_machine = frame_sink.clone(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut m = Box::new(Machine::new(cfg_owned)); + m.register_system_controller(); + // Install the capture renderer before the CPU starts + // so the very first REX3 frame already lands in the + // sink the GUI can read. + if let Some(rex3) = m.get_rex3() { + *rex3.renderer.lock() = + Some(Box::new(CaptureRenderer::new(sink_for_machine))); + } + m.start(); + m + })); + match result { + Ok(m) => { + *ps2_slot.lock() = Some(m.get_ps2()); + // Latch REX3's cycle counter for the live MIPS estimate. + cycles = m.get_rex3().map(|r| r.cycles.clone()); + prev_cycles = cycles + .as_ref() + .map(|c| c.load(std::sync::atomic::Ordering::Relaxed)) + .unwrap_or(0); + prev_tick = std::time::Instant::now(); + machine = Some(m); + let _ = evt_tx.send(Evt::Started); + } + Err(panic) => { + let msg = panic_msg(&panic); + let _ = evt_tx.send(Evt::Error(format!("start failed: {msg}"))); + } + } + } + Ok(Cmd::Stop) => { + if let Some(m) = machine.take() { + *ps2_slot.lock() = None; + cycles = None; + // Always report the machine as stopped so the user regains + // control, even if the stop failed or had to be abandoned. + if let Err(msg) = stop_machine_timed(m) { + let _ = evt_tx.send(Evt::Error(msg)); + } + let _ = evt_tx.send(Evt::Stopped); + } else { + let _ = evt_tx.send(Evt::Error("not running".into())); + } + } + Ok(Cmd::SaveState(name)) => { + let Some(m) = machine.as_mut() else { + let _ = evt_tx.send(Evt::Error("save: not running".into())); + continue; + }; + // save_snapshot stops the CPU as part of its work; once it + // returns, restart the CPU so the user can keep using the + // machine without an explicit Start. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let r = m.save_snapshot(&name); + m.start(); + r + })); + match result { + Ok(Ok(())) => { let _ = evt_tx.send(Evt::StateSaved(name)); } + Ok(Err(e)) => { let _ = evt_tx.send(Evt::Error(format!("save '{name}' failed: {e}"))); } + Err(p) => { let _ = evt_tx.send(Evt::Error(format!("save panic: {}", panic_msg(&p)))); } + } + } + Ok(Cmd::RestoreState(name)) => { + let Some(m) = machine.as_mut() else { + let _ = evt_tx.send(Evt::Error("restore: not running".into())); + continue; + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + m.ci_restore(&name) + })); + match result { + Ok(Ok(())) => { let _ = evt_tx.send(Evt::StateRestored(name)); } + Ok(Err(e)) => { let _ = evt_tx.send(Evt::Error(format!("restore '{name}' failed: {e}"))); } + Err(p) => { let _ = evt_tx.send(Evt::Error(format!("restore panic: {}", panic_msg(&p)))); } + } + } + Ok(Cmd::Screenshot(path)) => { + // Pull the most recently rendered frame from the sink and + // encode as PNG. We do this in the worker (rather than the + // GUI thread) because PNG encoding is non-trivial CPU. + let frame = frame_sink.snapshot(); + if frame.width == 0 || frame.height == 0 { + let _ = evt_tx.send(Evt::Error("screenshot: no frame yet".into())); + continue; + } + match write_png(&path, frame.width as u32, frame.height as u32, &frame.rgba) { + Ok(()) => { let _ = evt_tx.send(Evt::Screenshot(path)); } + Err(e) => { let _ = evt_tx.send(Evt::Error(format!("screenshot failed: {e}"))); } + } + } + Ok(Cmd::Quit) | Err(_) => { + *ps2_slot.lock() = None; + if let Some(m) = machine.take() { + // Bounded so a wedged guest can't hang app exit (Drop joins + // this thread). If the stop is abandoned, the process is + // exiting anyway and the OS reclaims everything. + let _ = stop_machine_timed(m); + } + return; + } + } + } +} + +/// Stop a machine, but never block longer than `STOP_TIMEOUT`. `Machine::stop()` +/// starts with `cpu.stop()`, which waits for the CPU thread to acknowledge the +/// halt; a wedged guest can make that never return. We run it on a detached +/// helper thread and give up after the timeout — the helper thread and that +/// `Machine` then leak, but the caller (and the whole GUI) stays responsive. +fn stop_machine_timed(m: Box) -> Result<(), String> { + const STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + let (done_tx, done_rx) = std::sync::mpsc::channel::>(); + if std::thread::Builder::new() + .name("iris-gui-stop".into()) + .spawn(move || { + let mut m = m; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| m.stop())); + let _ = done_tx.send(r.map_err(|p| panic_msg(&p))); + }) + .is_err() + { + return Err("stop: failed to spawn worker thread".into()); + } + match done_rx.recv_timeout(STOP_TIMEOUT) { + Ok(Ok(())) => Ok(()), + Ok(Err(msg)) => Err(format!("stop failed: {msg}")), + Err(_) => Err("stop timed out after 5s — the machine appears wedged; abandoning it".into()), + } +} + +fn write_png(path: &std::path::Path, w: u32, h: u32, rgba: &[u8]) -> Result<(), String> { + use std::fs::File; + use std::io::BufWriter; + let file = File::create(path).map_err(|e| e.to_string())?; + let mut enc = png::Encoder::new(BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header().map_err(|e| e.to_string())?; + writer.write_image_data(rgba).map_err(|e| e.to_string()) +} + +fn panic_msg(p: &Box) -> String { + if let Some(s) = p.downcast_ref::<&'static str>() { return (*s).to_string(); } + if let Some(s) = p.downcast_ref::() { return s.clone(); } + "".into() +} diff --git a/iris-gui/src/input.rs b/iris-gui/src/input.rs new file mode 100644 index 0000000..3480719 --- /dev/null +++ b/iris-gui/src/input.rs @@ -0,0 +1,266 @@ +//! Translate egui keyboard / pointer events into iris PS2 controller writes. +//! +//! ## Mouse / keyboard capture +//! +//! The guest's PS/2 mouse is *relative*: it reports motion deltas, and IRIX +//! draws its own pointer with its own acceleration. There is no way to keep a +//! relative guest pointer pixel-aligned with a visible host cursor — they +//! drift. So we adopt the standard emulator model (mirroring `src/ui.rs`): +//! **click the framebuffer to capture.** On capture we hide the host cursor +//! and lock it in place (`CursorGrab::Locked`); raw motion then arrives as +//! `egui::Event::MouseMoved` deltas (eframe forwards `DeviceEvent::MouseMotion` +//! regardless of grab), which we feed straight to the guest. Only the guest's +//! own pointer is visible, so there is nothing to misalign. **Ctrl+Alt+Esc +//! releases** (Alt is the Option key on macOS); a chord rather than bare Esc +//! so plain Esc still reaches the guest. +//! +//! While captured we also forward keyboard input to the guest; while *not* +//! captured we forward nothing, so menu clicks and typing into the config +//! side panel stay with egui. +//! +//! The framebuffer panel calls `pump(...)` each frame with the rect the REX3 +//! image occupies in screen space (used only to decide where a capturing +//! click counts). + +use egui::{CursorGrab, Event, Key, Modifiers, PointerButton, Rect, ViewportCommand}; +use iris::ps2::Ps2Controller; +use winit::keyboard::KeyCode; + +pub struct InputState { + last_mods: Modifiers, + last_buttons: u8, // bit0=L, bit1=R, bit2=M + /// True while the host cursor is grabbed and input is routed to the guest. + pub captured: bool, +} + +impl Default for InputState { + fn default() -> Self { + Self { last_mods: Modifiers::NONE, last_buttons: 0, captured: false } + } +} + +pub fn pump(ctx: &egui::Context, fb_rect: Rect, ps2: &Ps2Controller, state: &mut InputState) { + // Collect everything we need inside the input borrow, then act afterwards + // (sending viewport commands / PS2 writes outside the `input()` closure). + let mut want_enter = false; + let mut want_release = false; + let mut dx = 0.0f32; + let mut dy = 0.0f32; + let mut buttons = state.last_buttons; + let mut mods = state.last_mods; + let mut keys: Vec<(KeyCode, bool)> = Vec::new(); + + ctx.input(|i| { + if !state.captured { + // Not captured: the only thing we care about is a primary click + // inside the framebuffer, which grabs input. Everything else is + // left to egui (menus, config side panel, …). + if i.pointer.button_pressed(PointerButton::Primary) { + if let Some(p) = i.pointer.interact_pos().or_else(|| i.pointer.latest_pos()) { + if fb_rect.contains(p) { want_enter = true; } + } + } + return; + } + + // Captured. Ctrl+Alt+Esc (Alt == Option on macOS) — or losing window + // focus (alt-tab) — releases. Using a chord rather than bare Esc lets + // plain Esc reach the guest. + if (i.key_pressed(Key::Escape) && i.modifiers.ctrl && i.modifiers.alt) || !i.focused { + want_release = true; + return; + } + + mods = i.modifiers; + + for ev in &i.events { + match ev { + // Raw relative motion (eframe → DeviceEvent::MouseMotion). + Event::MouseMoved(d) => { dx += d.x; dy += d.y; } + Event::Key { key, pressed, .. } => { + if let Some(kc) = map_key(*key) { keys.push((kc, *pressed)); } + } + _ => {} + } + } + + let mut b = 0u8; + if i.pointer.button_down(PointerButton::Primary) { b |= 0x01; } + if i.pointer.button_down(PointerButton::Secondary) { b |= 0x02; } + if i.pointer.button_down(PointerButton::Middle) { b |= 0x04; } + buttons = b; + }); + + if want_enter { + state.captured = true; + // Anchor modifier/button state so we don't synth a spurious press for + // a key/button already held at capture time. + state.last_mods = ctx.input(|i| i.modifiers); + state.last_buttons = 0; + ctx.send_viewport_cmd(ViewportCommand::CursorVisible(false)); + ctx.send_viewport_cmd(ViewportCommand::CursorGrab(grab_mode())); + return; + } + + if want_release { + release_capture(ctx, ps2, state); + return; + } + + // ---- modifiers: diff previous → current, synth press/release. ---- + let m = mods; + if m.shift && !state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, true); } + if !m.shift && state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } + if m.ctrl && !state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, true); } + if !m.ctrl && state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } + if m.alt && !state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, true); } + if !m.alt && state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, false); } + if m.mac_cmd && !state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, true); } + if !m.mac_cmd && state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } + state.last_mods = m; + + // ---- key events ---- + for (kc, pressed) in keys { ps2.push_kb(kc, pressed); } + + // ---- mouse: raw per-frame delta + button diff. ---- + let (mdx, mdy) = (dx as i32, dy as i32); + if buttons != state.last_buttons || mdx != 0 || mdy != 0 { + send_mouse_packet(ps2, buttons, mdx, -mdy); // PS/2 Y axis is up-positive + state.last_buttons = buttons; + } +} + +/// Pick the cursor-grab mode winit actually supports on this platform/session. +/// +/// winit's two grab modes are not portable: `Locked` (lock the cursor in place +/// and deliver relative motion) is supported on macOS and Wayland but **not +/// X11**, while `Confined` (keep the cursor inside the window) is supported on +/// X11 and Windows but **not macOS**. egui-winit does no fallback — it just +/// logs the error — so a blanket `Locked` silently fails on X11, leaving the +/// cursor un-grabbed: it drifts off-window, loses focus, and capture drops. +/// +/// We rely on raw `DeviceEvent::MouseMotion` deltas (which arrive regardless of +/// grab mode), so `Confined` is sufficient on X11 — it just stops the cursor +/// escaping the window. Detect Wayland via `WAYLAND_DISPLAY`; otherwise assume +/// X11 on Linux. macOS/Windows keep `Locked`. +fn grab_mode() -> CursorGrab { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + CursorGrab::Locked + } else { + CursorGrab::Confined + } + } + #[cfg(not(target_os = "linux"))] + { + CursorGrab::Locked + } +} + +/// Release a capture: show + ungrab the host cursor and lift any modifiers +/// we'd synthesised, so the guest doesn't see stuck keys. Safe to call when +/// not captured (no-op). Used both for Esc/focus-loss and when the emulator +/// stops while the framebuffer still had the grab. +pub fn release_capture(ctx: &egui::Context, ps2: &Ps2Controller, state: &mut InputState) { + if !state.captured { return; } + if state.last_mods.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } + if state.last_mods.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } + if state.last_mods.alt { ps2.push_kb(KeyCode::AltLeft, false); } + if state.last_mods.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } + state.captured = false; + state.last_mods = Modifiers::NONE; + state.last_buttons = 0; + ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); + ctx.send_viewport_cmd(ViewportCommand::CursorGrab(CursorGrab::None)); +} + +/// Ungrab the cursor without touching the guest (it may already be gone). +/// Called when the emulator stops while the framebuffer still held capture, +/// so the host cursor doesn't stay hidden/locked. No-op when not captured. +pub fn force_release(ctx: &egui::Context, state: &mut InputState) { + if !state.captured { return; } + state.captured = false; + state.last_mods = Modifiers::NONE; + state.last_buttons = 0; + ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); + ctx.send_viewport_cmd(ViewportCommand::CursorGrab(CursorGrab::None)); +} + +/// Build and dispatch one PS/2 mouse packet. Mirrors `src/ui.rs:646–658`: +/// byte 0 bit3 always 1, bits 2..0 are buttons (M/R/L), bit 4 = X sign, +/// bit 5 = Y sign, bits 6/7 = X/Y overflow. +fn send_mouse_packet(ps2: &Ps2Controller, buttons: u8, dx: i32, dy: i32) { + // Clamp to the 9-bit signed range expected by the protocol. Real + // drivers split large moves; that's fine to skip here because egui + // delivers small per-frame deltas. + let sx = dx.clamp(-256, 255); + let sy = dy.clamp(-256, 255); + let mut b0 = 0x08 | (buttons & 0x07); + if sx < 0 { b0 |= 0x10; } + if sy < 0 { b0 |= 0x20; } + if sx < -256 || sx > 255 { b0 |= 0x40; } + if sy < -256 || sy > 255 { b0 |= 0x80; } + ps2.push_mouse_packet(b0, sx as u8, sy as u8); +} + +/// egui::Key → winit::keyboard::KeyCode. Returns None for keys iris's +/// scancode mapper doesn't recognise (we just drop them rather than +/// inventing a fallback). +fn map_key(k: Key) -> Option { + Some(match k { + // Letters + Key::A => KeyCode::KeyA, Key::B => KeyCode::KeyB, Key::C => KeyCode::KeyC, + Key::D => KeyCode::KeyD, Key::E => KeyCode::KeyE, Key::F => KeyCode::KeyF, + Key::G => KeyCode::KeyG, Key::H => KeyCode::KeyH, Key::I => KeyCode::KeyI, + Key::J => KeyCode::KeyJ, Key::K => KeyCode::KeyK, Key::L => KeyCode::KeyL, + Key::M => KeyCode::KeyM, Key::N => KeyCode::KeyN, Key::O => KeyCode::KeyO, + Key::P => KeyCode::KeyP, Key::Q => KeyCode::KeyQ, Key::R => KeyCode::KeyR, + Key::S => KeyCode::KeyS, Key::T => KeyCode::KeyT, Key::U => KeyCode::KeyU, + Key::V => KeyCode::KeyV, Key::W => KeyCode::KeyW, Key::X => KeyCode::KeyX, + Key::Y => KeyCode::KeyY, Key::Z => KeyCode::KeyZ, + // Digits + Key::Num0 => KeyCode::Digit0, Key::Num1 => KeyCode::Digit1, + Key::Num2 => KeyCode::Digit2, Key::Num3 => KeyCode::Digit3, + Key::Num4 => KeyCode::Digit4, Key::Num5 => KeyCode::Digit5, + Key::Num6 => KeyCode::Digit6, Key::Num7 => KeyCode::Digit7, + Key::Num8 => KeyCode::Digit8, Key::Num9 => KeyCode::Digit9, + // Navigation / editing + Key::Escape => KeyCode::Escape, + Key::Tab => KeyCode::Tab, + Key::Backspace => KeyCode::Backspace, + Key::Enter => KeyCode::Enter, + Key::Space => KeyCode::Space, + Key::Insert => KeyCode::Insert, + Key::Delete => KeyCode::Delete, + Key::Home => KeyCode::Home, + Key::End => KeyCode::End, + Key::PageUp => KeyCode::PageUp, + Key::PageDown => KeyCode::PageDown, + Key::ArrowUp => KeyCode::ArrowUp, + Key::ArrowDown => KeyCode::ArrowDown, + Key::ArrowLeft => KeyCode::ArrowLeft, + Key::ArrowRight => KeyCode::ArrowRight, + // Punctuation + Key::Comma => KeyCode::Comma, + Key::Period => KeyCode::Period, + Key::Slash => KeyCode::Slash, + Key::Backslash => KeyCode::Backslash, + Key::Minus => KeyCode::Minus, + Key::Equals => KeyCode::Equal, + Key::Plus => KeyCode::Equal, // shifted: same physical key + Key::Semicolon => KeyCode::Semicolon, + Key::Colon => KeyCode::Semicolon, + Key::Quote => KeyCode::Quote, + Key::OpenBracket => KeyCode::BracketLeft, + Key::CloseBracket => KeyCode::BracketRight, + Key::Backtick => KeyCode::Backquote, + // F-keys (egui has no F5; iris likely doesn't need F13+ either) + Key::F1 => KeyCode::F1, Key::F2 => KeyCode::F2, Key::F3 => KeyCode::F3, + Key::F4 => KeyCode::F4, Key::F6 => KeyCode::F6, Key::F7 => KeyCode::F7, + Key::F8 => KeyCode::F8, Key::F9 => KeyCode::F9, Key::F10 => KeyCode::F10, + // F11 is consumed by the GUI (fullscreen toggle); don't forward. + Key::F12 => KeyCode::F12, + _ => return None, + }) +} diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs new file mode 100644 index 0000000..66d7288 --- /dev/null +++ b/iris-gui/src/main.rs @@ -0,0 +1,988 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod config_ui; +mod dialogs; +mod framebuffer; +mod handle; +mod input; +mod safe_stop; +mod scsi_menu; +mod settings; +mod single_instance; + +use config_ui::{cfg_to_toml, show_tab, JitEnv, Tab}; +use dialogs::create_disk::CreateDiskDialog; +use dialogs::new_machine::{distribute_ram, NewMachineDialog}; +use eframe::egui; +use egui::{Color32, RichText, ViewportCommand}; +use handle::{Cmd, EmulatorHandle, Evt}; +use iris::config::MachineConfig; +use safe_stop::{evaluate, reason_lines}; +use settings::{GuiSettings, UI_SCALE_MAX, UI_SCALE_MIN}; +use std::path::PathBuf; + +/// Decode the bundled window/taskbar icon (256×256 RGBA PNG, generated by +/// `scripts/generate-icon.sh` from `iris-gui/assets/icon-original.png`). +/// Decoded with the `png` crate that iris-gui already depends on, so no extra +/// image dependency is pulled in. +fn load_icon() -> egui::IconData { + const ICON_PNG: &[u8] = include_bytes!("../assets/icons/icon-256.png"); + let decoder = png::Decoder::new(std::io::Cursor::new(ICON_PNG)); + let mut reader = decoder.read_info().expect("decode icon PNG header"); + let mut rgba = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut rgba).expect("decode icon PNG pixels"); + rgba.truncate(info.buffer_size()); + egui::IconData { + rgba, + width: info.width, + height: info.height, + } +} + +fn main() -> eframe::Result<()> { + env_logger::init(); + // Prevent the iris lib from calling process::exit on guest soft-power-off + // or CI `quit`. iris-gui never wants the embedder to die from a guest event. + // Set this once, before any worker thread can read it. + std::env::set_var("IRIS_NO_EXIT_ON_POWEROFF", "1"); + // Terminate a still-alive previous instance (a crash/hang or forgotten + // copy that would otherwise keep the monitor/serial ports bound) and claim + // the single-instance lock for ourselves. + single_instance::acquire(); + let prefs = GuiSettings::load(); + let mut viewport = egui::ViewportBuilder::default() + .with_title("iris — SGI Indy emulator") + // app_id sets the X11 WM_CLASS / Wayland app_id so the compositor can + // match an installed .desktop/icon (icons regenerated by + // scripts/generate-icon.sh from iris-gui/assets/icon-original.png). + .with_app_id("iris-gui") + .with_icon(load_icon()) + .with_inner_size(prefs.window_size.unwrap_or([1100.0, 720.0])); + if prefs.fullscreen { + viewport = viewport.with_fullscreen(true); + } + let opts = eframe::NativeOptions { + viewport, + ..Default::default() + }; + eframe::run_native( + "iris-gui", + opts, + Box::new(|cc| { + cc.egui_ctx.set_zoom_factor(prefs.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)); + Ok(Box::new(App::new(prefs))) + }), + ) +} + +struct App { + prefs: GuiSettings, + cfg: MachineConfig, + /// Path to an iris.toml that was *imported* (for re-export). None for + /// machines that originated from the GUI. + cfg_path: Option, + /// Marks the current cfg as having unsaved (in-memory) changes. + cfg_dirty: bool, + /// Timestamp of the most recent edit; used to debounce auto-save. + cfg_dirty_since: Option, + jit: JitEnv, + tab: Tab, + emu: EmulatorHandle, + toast: Option<(String, std::time::Instant)>, + fullscreen: bool, + stop_modal: Option, + missing_modal: Option, + new_machine: NewMachineDialog, + create_disk: CreateDiskDialog, + /// If true, central panel shows the tabbed config editor; otherwise the + /// welcome/status summary panel (default — most config lives in menus). + show_config_editor: bool, + save_state_name: String, + restore_state_name: String, + /// egui texture holding the most recent REX3 framebuffer. Allocated + /// lazily on the first frame that needs it. + fb_tex: Option, + /// Sequence number of the last frame we uploaded; used to skip the + /// upload when the renderer hasn't produced a new frame. + last_fb_seq: u64, + /// Per-frame state for the egui→PS2 input pump (modifier diff, + /// mouse button mask, last cursor position). + input_state: input::InputState, +} + +struct StopModal { + lines: Vec, +} + +/// One SCSI device that is missing its backing file at Start time. +struct MissingDisk { + id: u8, + path: String, + cdrom: bool, +} + +/// Modal shown when one or more SCSI image files are missing on Start. +/// `Machine::new` would otherwise call `std::process::exit(1)` and take +/// the whole GUI down with it. +struct MissingDiskModal { + missing: Vec, +} + +impl App { + fn new(mut prefs: GuiSettings) -> Self { + // Resolution order on startup: + // 1. prefs.active_machine present + in prefs.machines → load it. + // 2. legacy prefs.last_config TOML still on disk → migrate it as + // a new named machine and adopt it. + // 3. otherwise: open the New Machine dialog. + let mut cfg = MachineConfig::default(); + let mut cfg_path: Option = None; + let mut opened_new_machine = false; + let mut new_machine = NewMachineDialog::default(); + + if let Some(name) = prefs.active_machine.clone() { + if let Some(stored) = prefs.machines.get(&name).cloned() { + cfg = stored; + } else { + // Stale pointer — fall through to migrate / dialog. + prefs.active_machine = None; + } + } + if prefs.active_machine.is_none() { + if let Some(p) = prefs.last_config.clone() { + if p.exists() { + cfg = MachineConfig::load_toml(&p.to_string_lossy()); + cfg_path = Some(p.clone()); + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); + let name = prefs.unique_name(stem); + prefs.machines.insert(name.clone(), cfg.clone()); + prefs.active_machine = Some(name); + // Clear the legacy pointer so we don't re-migrate next run. + prefs.last_config = None; + let _ = prefs.save(); + } + } + } + if prefs.active_machine.is_none() { + new_machine.open(); + opened_new_machine = true; + } + let _ = opened_new_machine; // (kept for future telemetry) + + Self { + fullscreen: prefs.fullscreen, + prefs, + cfg, + cfg_path, + cfg_dirty: false, + cfg_dirty_since: None, + jit: JitEnv::default(), + tab: Tab::General, + emu: EmulatorHandle::spawn(), + toast: None, + stop_modal: None, + missing_modal: None, + new_machine, + create_disk: CreateDiskDialog::default(), + show_config_editor: false, + save_state_name: "snap1".into(), + restore_state_name: "snap1".into(), + fb_tex: None, + last_fb_seq: 0, + input_state: input::InputState::default(), + } + } + + fn toast(&mut self, msg: impl Into) { + self.toast = Some((msg.into(), std::time::Instant::now())); + } + + /// Mark the in-memory config as edited and arm the auto-save debounce. + fn mark_dirty(&mut self) { + self.cfg_dirty = true; + self.cfg_dirty_since = Some(std::time::Instant::now()); + } + + /// Persist the current machine to disk and clear the dirty flag. + fn flush_machine(&mut self) { + if let Some(name) = self.prefs.active_machine.clone() { + self.prefs.machines.insert(name, self.cfg.clone()); + if let Err(e) = self.prefs.save() { + self.toast(format!("autosave failed: {e}")); + return; + } + } + self.cfg_dirty = false; + self.cfg_dirty_since = None; + } + + /// Debounced auto-save: flush ~600 ms after the most recent edit. + fn maybe_autosave(&mut self) { + if let Some(t) = self.cfg_dirty_since { + if t.elapsed().as_millis() >= 600 { self.flush_machine(); } + } + } + + /// Switch the active machine in-memory and on disk. + fn switch_to(&mut self, name: &str) { + // Flush whatever we were holding first. + if self.cfg_dirty { self.flush_machine(); } + if let Some(cfg) = self.prefs.machines.get(name).cloned() { + self.cfg = cfg; + self.cfg_path = None; + self.prefs.active_machine = Some(name.to_string()); + let _ = self.prefs.save(); + self.toast(format!("loaded '{name}'")); + } + } + + fn save_config(&mut self, path: PathBuf) { + match cfg_to_toml(&self.cfg) { + Ok(s) => match std::fs::write(&path, s) { + Ok(_) => { + self.prefs.push_recent(path.clone()); + self.cfg_path = Some(path); + self.cfg_dirty = false; + self.toast("config saved"); + } + Err(e) => self.toast(format!("save failed: {e}")), + }, + Err(e) => self.toast(format!("serialize failed: {e}")), + } + } + + fn start_emulator(&mut self) { + // Flush any pending edits before the machine starts so the on-disk + // copy matches what we're about to boot. + if self.cfg_dirty { self.flush_machine(); } + if iris::build_features::LIGHTNING && self.cfg.gdb_port.is_some() { + // GDB stub is a no-op under lightning; silently drop the setting + // so we don't hand the executor a port it can't honour. + self.cfg.gdb_port = None; + } + if let Err(e) = self.cfg.validate() { + self.toast(format!("invalid config: {e}")); + return; + } + // Preflight: Machine::new will call std::process::exit(1) on the + // first SCSI device whose backing file is missing — we'd lose the + // GUI process. Catch that case here and prompt the user instead. + let missing = self.missing_disks(); + if !missing.is_empty() { + self.missing_modal = Some(MissingDiskModal { missing }); + return; + } + // Surface the embedded-PROM fallback so it's clear the start did + // happen (iris::prom::Prom::from_file_or_embedded handles this + // transparently — we just echo it to the toast). + if !std::path::Path::new(&self.cfg.prom).exists() { + self.toast(format!("'{}' not found — using embedded PROM", self.cfg.prom)); + } + self.jit.export(); + self.emu.send(Cmd::Start(Box::new(self.cfg.clone()))); + } + + /// Walk the configured SCSI devices and report any whose image file + /// is missing. Scratch volumes are skipped (iris auto-creates those). + /// For CD-ROMs the device is "present" if either the primary path or + /// any disc in the changer list exists. + fn missing_disks(&self) -> Vec { + let mut out = Vec::new(); + for (&id, dev) in &self.cfg.scsi { + if dev.scratch { continue; } + // Empty CD-ROM (no path, no changer entries) means "drive present, + // tray empty" — a valid configured state, not missing. + if dev.cdrom && dev.path.is_empty() && dev.discs.is_empty() { + continue; + } + let primary_ok = !dev.path.is_empty() && std::path::Path::new(&dev.path).exists(); + let any_disc_ok = dev.discs.iter().any(|d| std::path::Path::new(d).exists()); + let present = primary_ok || (dev.cdrom && any_disc_ok); + if !present { + out.push(MissingDisk { id, path: dev.path.clone(), cdrom: dev.cdrom }); + } + } + out.sort_by_key(|m| m.id); + out + } + + /// Detach the given SCSI IDs from the live config and (optionally) try Start again. + fn detach_and_start(&mut self, ids: &[u8]) { + for id in ids { self.cfg.scsi.remove(id); } + self.mark_dirty(); + self.start_emulator(); + } + + fn request_stop(&mut self) { + let reasons = evaluate(&self.emu.status, &self.cfg); + if reasons.is_empty() { + self.emu.send(Cmd::Stop); + } else { + self.stop_modal = Some(StopModal { lines: reason_lines(&reasons) }); + } + } + + fn handle_events(&mut self, ctx: &egui::Context) { + for evt in self.emu.drain_events() { + match evt { + Evt::Started => self.toast("emulator started"), + Evt::Stopped => self.toast("emulator stopped"), + Evt::PowerOff => self.toast("guest powered off (safe to stop)"), + Evt::StateSaved(n) => self.toast(format!("state saved: {n}")), + Evt::StateRestored(n) => self.toast(format!("state restored: {n}")), + Evt::Screenshot(p) => self.toast(format!("screenshot: {}", p.display())), + Evt::Error(e) => self.toast(format!("error: {e}")), + Evt::Status(_) => {} + } + } + // Repaint cadence: ~60 fps while the emulator is running so we + // can pull the latest framebuffer; lazy otherwise to save CPU. + let next = if self.emu.is_running() { 16 } else { 250 }; + ctx.request_repaint_after(std::time::Duration::from_millis(next)); + } + + fn menu_bar(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + egui::menu::bar(ui, |ui| { + ui.menu_button("File", |ui| { + ui.set_min_width(220.0); + if ui.button("New machine…").clicked() { + self.new_machine.open(); + ui.close_menu(); + } + ui.menu_button("Switch to machine", |ui| { + ui.set_min_width(200.0); + let active = self.prefs.active_machine.clone(); + let names: Vec = self.prefs.machines.keys().cloned().collect(); + if names.is_empty() { + ui.label(RichText::new("(no saved machines yet)").weak()); + } + let mut want_switch: Option = None; + for name in names { + let marker = if active.as_deref() == Some(&name) { "● " } else { " " }; + if ui.button(format!("{marker}{name}")).clicked() { + want_switch = Some(name); + ui.close_menu(); + } + } + if let Some(n) = want_switch { self.switch_to(&n); } + }); + ui.menu_button("Rename current…", |ui| { + let cur = self.prefs.active_machine.clone(); + if let Some(name) = cur { + ui.label(format!("Current: {name}")); + let mut new_name = name.clone(); + ui.text_edit_singleline(&mut new_name); + if ui.button("Rename").clicked() && !new_name.trim().is_empty() && new_name != name { + let n = self.prefs.unique_name(new_name.trim()); + if let Some(cfg) = self.prefs.machines.remove(&name) { + self.prefs.machines.insert(n.clone(), cfg); + self.prefs.active_machine = Some(n.clone()); + let _ = self.prefs.save(); + self.toast(format!("renamed → '{n}'")); + } + ui.close_menu(); + } + } else { + ui.label(RichText::new("(no active machine)").weak()); + } + }); + let active = self.prefs.active_machine.clone(); + if ui.add_enabled(active.is_some(), egui::Button::new("Delete current machine")).clicked() { + if let Some(name) = active { + self.prefs.machines.remove(&name); + self.prefs.active_machine = self.prefs.machines.keys().next().cloned(); + if let Some(next) = self.prefs.active_machine.clone() { + self.cfg = self.prefs.machines[&next].clone(); + } else { + self.cfg = MachineConfig::default(); + self.new_machine.open(); + } + let _ = self.prefs.save(); + self.toast(format!("deleted '{name}'")); + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Import iris.toml…").clicked() { + if let Some(path) = native_open_dialog("Import iris.toml", &[("TOML", &["toml"])]) { + let cfg = MachineConfig::load_toml(&path.to_string_lossy()); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); + let name = self.prefs.unique_name(stem); + self.prefs.machines.insert(name.clone(), cfg.clone()); + self.prefs.active_machine = Some(name.clone()); + self.cfg = cfg; + self.cfg_path = Some(path); + self.flush_machine(); + self.toast(format!("imported as '{name}'")); + } + ui.close_menu(); + } + if ui.button("Export current to iris.toml…").clicked() { + if let Some(path) = native_save_dialog("Export iris.toml", &[("TOML", &["toml"])]) { + self.save_config(path); + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Quit").clicked() { + if self.cfg_dirty { self.flush_machine(); } + ctx.send_viewport_cmd(ViewportCommand::Close); + } + }); + ui.menu_button("Machine", |ui| { + let running = self.emu.is_running(); + if ui.add_enabled(!running, egui::Button::new("Start")).clicked() { + self.start_emulator(); + ui.close_menu(); + } + if ui.add_enabled(running, egui::Button::new("Stop")).clicked() { + self.request_stop(); + ui.close_menu(); + } + if ui.add_enabled(running, egui::Button::new("Reset")).clicked() { + self.emu.send(Cmd::Stop); + self.start_emulator(); + ui.close_menu(); + } + ui.separator(); + ui.horizontal(|ui| { + ui.label("Save state:"); + ui.add(egui::TextEdit::singleline(&mut self.save_state_name).desired_width(120.0)); + if ui.button("Save").clicked() { + self.emu.send(Cmd::SaveState(self.save_state_name.clone())); + } + }); + ui.horizontal(|ui| { + ui.label("Restore state:"); + ui.add(egui::TextEdit::singleline(&mut self.restore_state_name).desired_width(120.0)); + if ui.button("Restore").clicked() { + self.emu.send(Cmd::RestoreState(self.restore_state_name.clone())); + } + }); + ui.separator(); + if ui.button("Screenshot…").clicked() { + if let Some(p) = native_save_dialog("Save screenshot", &[("PNG", &["png"])]) { + self.emu.send(Cmd::Screenshot(p)); + } + ui.close_menu(); + } + }); + ui.menu_button("Memory", |ui| { + ui.set_min_width(220.0); + let total: u32 = self.cfg.banks.iter().sum(); + ui.label(RichText::new(format!("Total: {total} MB")).strong()); + ui.separator(); + ui.label("Quick presets (auto-distributed):"); + for &p in &[32u32, 64, 96, 128, 192, 256] { + if ui.button(format!("{p} MB")).clicked() { + self.cfg.banks = distribute_ram(p); + self.mark_dirty(); + self.toast(format!("RAM set to {p} MB ({:?})", self.cfg.banks)); + ui.close_menu(); + } + } + ui.separator(); + ui.label("Per-bank (advanced):"); + for i in 0..4 { + ui.menu_button(format!("Bank {i}: {} MB", self.cfg.banks[i]), |ui| { + for &sz in iris::config::VALID_BANK_SIZES { + if ui.button(format!("{sz} MB")).clicked() { + self.cfg.banks[i] = sz; + self.mark_dirty(); + ui.close_menu(); + } + } + }); + } + }); + ui.menu_button("SCSI", |ui| { + let action = scsi_menu::draw(ui, &self.cfg); + match action { + scsi_menu::ScsiAction::None => {} + scsi_menu::ScsiAction::CreateBlank { id } => { + self.create_disk.open_for(id); + } + other => { + if let Some(msg) = scsi_menu::apply(&mut self.cfg, other) { + self.mark_dirty(); + self.toast(msg); + } + } + } + }); + ui.menu_button("View", |ui| { + if ui.button(if self.fullscreen { "Exit fullscreen (F11)" } else { "Fullscreen (F11)" }).clicked() { + self.fullscreen = !self.fullscreen; + ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen)); + ui.close_menu(); + } + ui.horizontal(|ui| { + ui.label("UI scale"); + // Adjust the slider freely; only commit to the live zoom + // factor on Apply. Applying mid-drag rescales the whole UI + // (slider included) under the cursor, which makes the value + // jump around — hence the explicit button. + ui.add(egui::Slider::new(&mut self.prefs.ui_scale, UI_SCALE_MIN..=UI_SCALE_MAX)); + if ui.button("Apply").clicked() { + ctx.set_zoom_factor(self.prefs.ui_scale); + } + }); + ui.label(RichText::new("Ctrl+= / Ctrl+- / Ctrl+0 to zoom").weak().small()); + }); + ui.menu_button("Help", |ui| { + ui.label("iris-gui — SGI Indy emulator launcher"); + ui.label(format!("iris-gui {}", env!("CARGO_PKG_VERSION"))); + ui.separator(); + ui.label(RichText::new("Build features:").strong()); + use iris::build_features as bf; + ui.label(format!(" chd: {}", if bf::CHD { "on" } else { "off" })); + ui.label(format!(" camera: {}", if bf::CAMERA { "on" } else { "off" })); + ui.label(format!(" jit: {}", if bf::JIT { "on" } else { "off" })); + ui.label(format!(" rex-jit: {}", if bf::REX_JIT { "on" } else { "off" })); + ui.label(format!(" lightning: {}", if bf::LIGHTNING { "on (no debug)" } else { "off" })); + ui.hyperlink_to("README", "https://github.com/dsarfati/iris"); + }); + }); + } + + fn toolbar(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let running = self.emu.is_running(); + if !running { + if ui.add(egui::Button::new(RichText::new("▶ Start").size(16.0)) + .fill(Color32::from_rgb(40, 110, 40))).clicked() + { + self.start_emulator(); + } + } else if ui.add(egui::Button::new(RichText::new("■ Stop").size(16.0)) + .fill(Color32::from_rgb(160, 60, 60))).clicked() + { + self.request_stop(); + } + ui.separator(); + if ui.add_enabled(running, egui::Button::new("💾 Save state")).clicked() { + self.emu.send(Cmd::SaveState(self.save_state_name.clone())); + } + if ui.add_enabled(running, egui::Button::new("↶ Restore state")).clicked() { + self.emu.send(Cmd::RestoreState(self.restore_state_name.clone())); + } + ui.separator(); + let edit_label = if self.show_config_editor { "Hide config editor" } else { "Edit config…" }; + if ui.button(edit_label).clicked() { + self.show_config_editor = !self.show_config_editor; + } + if self.show_config_editor { + if ui.button("Network").clicked() { self.tab = Tab::Network; } + if ui.button("Video-In").clicked() { self.tab = Tab::VideoIn; } + if ui.button("Debug").clicked() { self.tab = Tab::Debug; } + if ui.button("CI").clicked() { self.tab = Tab::Ci; } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let status = if self.emu.status.power_off_seen { + RichText::new("halted").color(Color32::LIGHT_GRAY) + } else if self.emu.status.in_prom { + RichText::new("PROM").color(Color32::LIGHT_BLUE) + } else if running { + RichText::new("IRIX running").color(Color32::LIGHT_GREEN) + } else { + RichText::new("stopped").color(Color32::GRAY) + }; + ui.label(status); + if running { + ui.label(format!("{:.0} MIPS", self.emu.status.mips)); + } + }); + }); + } + + /// Draw the live REX3 framebuffer as an egui image, scaled to fit + /// the available area while preserving aspect ratio. + fn framebuffer_panel(&mut self, ui: &mut egui::Ui) { + // Lock-free check first: only clone + re-upload the (multi-MB) + // framebuffer when REX3 has actually produced a new frame. Cloning on + // every 60 fps repaint regardless was ~300 MB/s of pointless copying + // and needless lock contention with the REX3 refresh thread. + let seq = self.emu.frame_sink.seq(); + if seq == 0 { + ui.centered_and_justified(|ui| { + ui.label(RichText::new("Emulator running — waiting for first REX3 frame…") + .color(Color32::LIGHT_GRAY)); + }); + return; + } + + if self.fb_tex.is_none() || seq != self.last_fb_seq { + let frame = self.emu.frame_sink.snapshot(); + if frame.width == 0 || frame.height == 0 { return; } + let img = egui::ColorImage::from_rgba_unmultiplied( + [frame.width, frame.height], &frame.rgba); + match &mut self.fb_tex { + Some(t) => t.set(img, egui::TextureOptions::NEAREST), + None => { + self.fb_tex = Some(ui.ctx().load_texture( + "rex3_fb", img, egui::TextureOptions::NEAREST)); + } + } + self.last_fb_seq = frame.seq; + } + + let mut fb_rect = egui::Rect::NOTHING; + if let Some(tex) = &self.fb_tex { + let avail = ui.available_size(); + let tex_size = tex.size_vec2(); + let fb_aspect = tex_size.x / tex_size.y; + let avail_aspect = avail.x / avail.y; + let size = if avail_aspect > fb_aspect { + egui::vec2(avail.y * fb_aspect, avail.y) + } else { + egui::vec2(avail.x, avail.x / fb_aspect) + }; + ui.centered_and_justified(|ui| { + let response = ui.add( + egui::Image::new((tex.id(), size)).fit_to_exact_size(size).sense(egui::Sense::click()) + ); + fb_rect = response.rect; + // Take keyboard focus so that egui delivers Key events + // to us instead of routing them to other widgets when + // the user clicks into the FB. + if response.clicked() { response.request_focus(); } + }); + } + + // Pump egui input → PS/2 controller. Mouse/keyboard only reach the + // guest while captured (click the framebuffer to capture, Ctrl+Alt+Esc + // to release), so menu clicks and config typing don't leak in. + let ps2 = self.emu.ps2.lock().clone(); + if let Some(ps2) = ps2 { + input::pump(ui.ctx(), fb_rect, &ps2, &mut self.input_state); + } + + // Capture hint, drawn over the framebuffer. + if fb_rect.is_positive() { + let hint = if self.input_state.captured { + "Ctrl+Alt+Esc to release mouse" + } else { + "Click to capture mouse / keyboard" + }; + ui.painter().text( + fb_rect.center_bottom() + egui::vec2(0.0, -6.0), + egui::Align2::CENTER_BOTTOM, + hint, + egui::FontId::proportional(12.0), + Color32::from_white_alpha(140), + ); + } + } + + fn central_tabs(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for &t in Tab::ALL { + ui.selectable_value(&mut self.tab, t, t.label()); + } + }); + ui.separator(); + show_tab(ui, self.tab, &mut self.cfg, &mut self.jit); + } + + fn welcome_panel(&mut self, ui: &mut egui::Ui) { + ui.add_space(8.0); + ui.heading("iris — SGI Indy emulator"); + ui.add_space(4.0); + + let name = self.prefs.active_machine.as_deref().unwrap_or("(unsaved)"); + ui.label(format!("Machine: {name}")); + if self.cfg_dirty { + ui.label(RichText::new("(autosave pending…)").weak().small()); + } + ui.add_space(8.0); + + let total_ram: u32 = self.cfg.banks.iter().sum(); + ui.label(RichText::new("Machine summary").strong()); + egui::Grid::new("summary_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("PROM"); + ui.label(if std::path::Path::new(&self.cfg.prom).exists() { + self.cfg.prom.clone() + } else { + format!("{} (missing → embedded fallback)", self.cfg.prom) + }); + ui.end_row(); + ui.label("NVRAM"); + ui.label(&self.cfg.nvram); + ui.end_row(); + ui.label("RAM"); + ui.label(format!("{total_ram} MB ({:?})", self.cfg.banks)); + ui.end_row(); + ui.label("Drives"); + ui.vertical(|ui| { + let mut ids: Vec = self.cfg.scsi.keys().copied().collect(); + ids.sort(); + if ids.is_empty() { + ui.label(RichText::new("(none)").weak()); + } + for id in ids { + let d = &self.cfg.scsi[&id]; + let kind = if d.cdrom { "CD" } else { "HDD" }; + ui.label(format!("scsi{id} {kind}: {}", d.path)); + } + ui.label(RichText::new("Use the SCSI menu to attach / detach / replace.").weak().small()); + }); + ui.end_row(); + ui.label("Network"); + ui.label(self.cfg.nat_subnet.clone().unwrap_or_else(|| "192.168.0.0/24 (default)".into())); + ui.end_row(); + }); + + ui.add_space(12.0); + // Welcome panel is only rendered when the emulator is stopped + // (the central panel switches to the framebuffer when running), + // so a single Start button is sufficient here. + if ui.add(egui::Button::new(RichText::new("▶ Start emulator").size(20.0)) + .fill(Color32::from_rgb(40, 120, 40)) + .min_size(egui::vec2(220.0, 44.0))).clicked() + { + self.start_emulator(); + } + } + + fn status_bar(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let name = self.prefs.active_machine.as_deref().unwrap_or("(unsaved)"); + ui.label(format!("Machine: {name}{}", if self.cfg_dirty { " *" } else { "" })); + ui.separator(); + ui.label(format!("Dirty COW: {}", self.emu.status.dirty_cow)); + if let Some((msg, when)) = self.toast.clone() { + if when.elapsed().as_secs() < 5 { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(msg).color(Color32::YELLOW)); + }); + } else { + self.toast = None; + } + } + }); + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.handle_events(ctx); + self.maybe_autosave(); + + // F11 toggles fullscreen. + if ctx.input(|i| i.key_pressed(egui::Key::F11)) { + self.fullscreen = !self.fullscreen; + ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen)); + } + + // Ctrl + / Ctrl - / Ctrl 0 zoom controls (helps on Linux where egui's + // default text size can look small on HiDPI / fractional-scale Wayland). + let (zoom_in, zoom_out, zoom_reset) = ctx.input(|i| ( + i.modifiers.command && (i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)), + i.modifiers.command && i.key_pressed(egui::Key::Minus), + i.modifiers.command && i.key_pressed(egui::Key::Num0), + )); + if zoom_in { self.prefs.ui_scale = (self.prefs.ui_scale + 0.1).min(UI_SCALE_MAX); ctx.set_zoom_factor(self.prefs.ui_scale); } + if zoom_out { self.prefs.ui_scale = (self.prefs.ui_scale - 0.1).max(UI_SCALE_MIN); ctx.set_zoom_factor(self.prefs.ui_scale); } + if zoom_reset { self.prefs.ui_scale = settings::UI_SCALE_DEFAULT; ctx.set_zoom_factor(self.prefs.ui_scale); } + + // In fullscreen, only reveal menu/toolbar when the cursor is near the top. + let pointer_y = ctx.input(|i| i.pointer.latest_pos().map(|p| p.y).unwrap_or(f32::MAX)); + let chrome_visible = !self.fullscreen || pointer_y < 36.0; + + if chrome_visible { + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| self.menu_bar(ui, ctx)); + egui::TopBottomPanel::top("toolbar").show(ctx, |ui| self.toolbar(ui)); + } + if !self.fullscreen { + egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| self.status_bar(ui)); + } + + // Config editor lives in a collapsible side panel so the emulator + // screen (central panel) is never hidden by it. The toolbar's + // "Edit config… / Hide config editor" toggle drives the collapse; + // `show_animated` slides it in/out. + egui::SidePanel::right("config_editor") + .resizable(true) + .default_width(420.0) + .show_animated(ctx, self.show_config_editor, |ui| { + ui.horizontal(|ui| { + ui.heading("Configuration"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("×").on_hover_text("Hide config editor").clicked() { + self.show_config_editor = false; + } + }); + }); + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| self.central_tabs(ui)); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + // The central panel always shows the emulator screen when the + // machine is running (the REX3 framebuffer), falling back to the + // welcome / status summary when idle. The config editor no longer + // takes this space — it's the side panel above. + if self.emu.is_running() { + self.framebuffer_panel(ui); + } else { + // Emulator not running: make sure a leftover mouse capture is + // released so the host cursor isn't stuck hidden/locked. + input::force_release(ui.ctx(), &mut self.input_state); + self.welcome_panel(ui); + } + }); + + // New machine dialog. + self.new_machine.show(ctx); + if let Some(result) = self.new_machine.take_result() { + let name = self.prefs.unique_name(&result.name); + self.prefs.machines.insert(name.clone(), result.cfg.clone()); + self.prefs.active_machine = Some(name.clone()); + self.cfg = result.cfg; + self.cfg_path = None; + self.flush_machine(); + self.toast(format!("created machine '{name}'")); + } + + // Create blank disk dialog. + self.create_disk.show(ctx); + if let Some(result) = self.create_disk.take_result() { + let path_str = result.path.to_string_lossy().into_owned(); + self.cfg.scsi.insert(result.scsi_id, iris::config::ScsiDeviceConfig { + path: path_str.clone(), discs: vec![], cdrom: false, + overlay: false, scratch: false, size_mb: None, + }); + self.mark_dirty(); + self.toast(format!("created {path_str} and attached at scsi{}", result.scsi_id)); + } + + // Safe-stop confirmation modal. + let mut close_modal = false; + let mut do_force = false; + let mut do_halt = false; + if let Some(modal) = &self.stop_modal { + egui::Window::new("Confirm stop") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new("Stopping now may corrupt the disk image:").strong()); + for line in &modal.lines { ui.label(format!("• {line}")); } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { close_modal = true; } + if ui.button("Send IRIX halt").clicked() { do_halt = true; close_modal = true; } + if ui.add(egui::Button::new(RichText::new("Force stop").color(Color32::WHITE)) + .fill(Color32::from_rgb(140, 40, 40))).clicked() + { + do_force = true; close_modal = true; + } + }); + }); + } + if close_modal { self.stop_modal = None; } + if do_force { self.emu.send(Cmd::Stop); } + if do_halt { + // iris always opens 127.0.0.1:8881 as the ttyd1 (IRIX serial + // console) TCP listener in non-CI mode. Connect to it, + // write "halt\n", disconnect. IRIX takes a few seconds to + // shut down cleanly; the user can hit Stop again once the + // PROM "halted" message appears. + use std::io::Write as _; + match std::net::TcpStream::connect_timeout( + &"127.0.0.1:8881".parse().unwrap(), + std::time::Duration::from_millis(500), + ) { + Ok(mut s) => { + let _ = s.write_all(b"halt\n"); + self.toast("sent 'halt' to IRIX — wait for shutdown, then Stop"); + } + Err(e) => { + self.toast(format!("halt failed: {e} — falling back to Force stop")); + self.emu.send(Cmd::Stop); + } + } + } + + // Missing-disk modal. + enum MissingChoice { None, Cancel, Detach, EditDisks } + let mut choice = MissingChoice::None; + if let Some(modal) = &self.missing_modal { + egui::Window::new("Missing disk image(s)") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new("The following SCSI image files are missing:").strong()); + for m in &modal.missing { + let kind = if m.cdrom { "CD-ROM" } else { "HDD" }; + ui.label(format!("• scsi{} ({kind}): {}", m.id, m.path)); + } + ui.add_space(8.0); + ui.label(RichText::new( + "iris would terminate the process if started in this state. \ + Choose how to proceed:").weak()); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { choice = MissingChoice::Cancel; } + if ui.button("Edit Disks tab").clicked() { choice = MissingChoice::EditDisks; } + if ui.add(egui::Button::new("Detach missing & start") + .fill(Color32::from_rgb(60, 90, 140))).clicked() + { + choice = MissingChoice::Detach; + } + }); + }); + } + match choice { + MissingChoice::None => {} + MissingChoice::Cancel => { self.missing_modal = None; } + MissingChoice::EditDisks => { + // Switching the tab alone is invisible if the config editor + // side panel is collapsed (its default state) — open it too, + // or the button appears to do nothing. + self.tab = config_ui::Tab::Disks; + self.show_config_editor = true; + self.missing_modal = None; + } + MissingChoice::Detach => { + let ids: Vec = self.missing_modal.as_ref() + .map(|m| m.missing.iter().map(|d| d.id).collect()) + .unwrap_or_default(); + self.missing_modal = None; + self.detach_and_start(&ids); + } + } + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.prefs.fullscreen = self.fullscreen; + // Make sure the latest cfg lands in `machines` before save(). + if self.cfg_dirty { self.flush_machine(); } else { let _ = self.prefs.save(); } + // Synchronously stop the machine and join the worker, so a running + // guest is cleaned up even if the user closed the window without + // pressing Stop (rather than relying on Drop ordering / racing + // process teardown). Bounded by the worker's stop timeout. + self.emu.shutdown(); + // Release the single-instance lock so the next launch sees a clean + // start (a crash that skips this just leaves a stale, harmless pidfile). + single_instance::release(); + } +} + +// --- platform dialogs ------------------------------------------------------ +// +// We avoid `rfd` as a dependency for now to keep the dep tree slim. Use +// `zenity` / `osascript` if available; otherwise return None and let the +// caller paste a path into the recent-files / save-state fields. +fn native_open_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { + let mut d = rfd::FileDialog::new().set_title(title); + for (name, exts) in filters { d = d.add_filter(*name, exts); } + d.pick_file() +} +fn native_save_dialog(title: &str, filters: &[(&str, &[&str])]) -> Option { + let mut d = rfd::FileDialog::new().set_title(title); + for (name, exts) in filters { d = d.add_filter(*name, exts); } + d.save_file() +} diff --git a/iris-gui/src/safe_stop.rs b/iris-gui/src/safe_stop.rs new file mode 100644 index 0000000..9504450 --- /dev/null +++ b/iris-gui/src/safe_stop.rs @@ -0,0 +1,63 @@ +use crate::handle::Status; +use iris::config::MachineConfig; + +/// Reasons it is risky to force-stop right now. +#[derive(Debug, Clone, Default)] +pub struct UnsafeReasons { + /// SCSI IDs of attached disks whose guest writes land directly in their + /// base image (a plain read-write HDD). Force-stopping mid-write can leave + /// the filesystem on these inconsistent. + pub writable_disks: Vec, +} + +impl UnsafeReasons { + pub fn is_empty(&self) -> bool { + self.writable_disks.is_empty() + } +} + +/// Evaluate whether stopping the emulator right now is safe. +/// +/// The core does not expose live dirty-sector state, so we decide purely from +/// config: an abrupt power-off only risks the on-disk image when some attached +/// device persists guest writes straight into its **base image** — i.e. a +/// plain read-write hard disk. Everything else leaves the base image untouched +/// and is safe to power off without warning: +/// +/// - **CD-ROM** — read-only. +/// - **COW overlay** (`overlay = true`) — writes go to a `{path}.overlay` +/// sidecar; the base image is never modified (delete the overlay to reset). +/// - **Scratch volume** (`scratch = true`) — a transient host-side file, not a +/// guest filesystem we need to protect. +/// - **CHD** (`*.chd`) — writes go to a `.diff.chd` sidecar; the base CHD is +/// never modified. +/// +/// So when no attached device writes through to its base image, powering off +/// will NOT damage the hard disk and we skip the confirmation dialog entirely. +pub fn evaluate(_status: &Status, cfg: &MachineConfig) -> UnsafeReasons { + let mut r = UnsafeReasons::default(); + for (id, dev) in &cfg.scsi { + let persists_to_base = !dev.cdrom + && !dev.overlay + && !dev.scratch + && !dev.path.ends_with(".chd"); + if persists_to_base { + r.writable_disks.push(*id); + } + } + r.writable_disks.sort(); + r +} + +/// Human-readable lines for the confirmation dialog. +pub fn reason_lines(r: &UnsafeReasons) -> Vec { + r.writable_disks + .iter() + .map(|id| { + format!( + "scsi{id} is a read-write disk image — force-stopping while IRIX \ + is running can corrupt its filesystem." + ) + }) + .collect() +} diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs new file mode 100644 index 0000000..770d56f --- /dev/null +++ b/iris-gui/src/scsi_menu.rs @@ -0,0 +1,188 @@ +use eframe::egui::{RichText, Ui}; +use iris::config::{MachineConfig, ScsiDeviceConfig}; +use std::path::Path; + +/// What the user picked from a SCSI submenu, deferred for the App to act on +/// (so we don't hold &mut MachineConfig across nested closures and dialogs). +pub enum ScsiAction { + None, + AttachHdd { id: u8, path: String }, + AttachEmptyCdrom { id: u8 }, + InsertDisc { id: u8, path: String }, + Eject { id: u8 }, + Detach { id: u8 }, + CreateBlank { id: u8 }, + ToggleOverlay { id: u8 }, +} + +/// Build the top-level "SCSI" menu. Returns at most one action per frame. +pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { + let mut action = ScsiAction::None; + ui.set_min_width(280.0); + for id in 1u8..=7 { + let dev = cfg.scsi.get(&id); + let label = render_label(id, dev); + ui.menu_button(label, |ui| { + ui.set_min_width(220.0); + match dev { + None => { + if ui.button("Attach HDD…").clicked() { + if let Some(p) = pick_disk("Attach HDD") { + action = ScsiAction::AttachHdd { id, path: p }; + } + ui.close_menu(); + } + // Attaching a CD-ROM gives an empty drive by default; the + // user loads media afterwards via "Insert disc…". Mirrors + // real hardware and avoids an upfront file prompt. + if ui.button("Attach CD-ROM drive (empty)").clicked() { + action = ScsiAction::AttachEmptyCdrom { id }; + ui.close_menu(); + } + if ui.button("Create blank HDD image…").clicked() { + action = ScsiAction::CreateBlank { id }; + ui.close_menu(); + } + } + Some(d) if d.cdrom => { + let has_media = !d.path.is_empty() && Path::new(&d.path).exists(); + if has_media { + if ui.button("Eject (tray empty)").clicked() { + action = ScsiAction::Eject { id }; + ui.close_menu(); + } + } + let insert_label = if has_media { "Swap disc…" } else { "Insert disc…" }; + if ui.button(insert_label).clicked() { + if let Some(p) = pick_iso("Insert disc") { + action = ScsiAction::InsertDisc { id, path: p }; + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Detach CD-ROM drive").clicked() { + action = ScsiAction::Detach { id }; + ui.close_menu(); + } + } + Some(d) => { + // HDD + let overlay_label = if d.overlay { + "Disable COW overlay" + } else { + "Enable COW overlay (writes → .overlay)" + }; + if ui.button(overlay_label).clicked() { + action = ScsiAction::ToggleOverlay { id }; + ui.close_menu(); + } + if ui.button("Replace image…").clicked() { + if let Some(p) = pick_disk("Replace HDD image") { + action = ScsiAction::AttachHdd { id, path: p }; + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Detach hard drive").clicked() { + action = ScsiAction::Detach { id }; + ui.close_menu(); + } + } + } + }); + } + ui.separator(); + ui.label(RichText::new( + "Reset the machine after attaching or detaching drives." + ).weak().small()); + action +} + +fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { + match dev { + None => format!("SCSI #{id}: (empty)"), + Some(d) if d.cdrom => { + if d.path.is_empty() { + format!("SCSI #{id}: CD (no media)") + } else if !Path::new(&d.path).exists() { + format!("SCSI #{id}: CD ⚠ {} (missing)", d.path) + } else { + let name = Path::new(&d.path).file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| d.path.clone()); + format!("SCSI #{id}: CD {name}") + } + } + Some(d) => { + let name = Path::new(&d.path).file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| d.path.clone()); + let size = std::fs::metadata(&d.path).map(|m| m.len()).unwrap_or(0); + let mb = size as f64 / (1024.0 * 1024.0); + let suffix = if d.overlay { " [COW]" } else { "" }; + if size > 0 { + format!("SCSI #{id}: HDD {name} ({mb:.0} MB){suffix}") + } else { + format!("SCSI #{id}: HDD {name}{suffix}") + } + } + } +} + +fn pick_disk(title: &str) -> Option { + rfd::FileDialog::new() + .set_title(title) + .add_filter("Disk images", &["raw", "img", "chd"]) + .add_filter("All", &["*"]) + .pick_file() + .map(|p| p.to_string_lossy().into_owned()) +} + +fn pick_iso(title: &str) -> Option { + rfd::FileDialog::new() + .set_title(title) + .add_filter("ISO images", &["iso", "chd"]) + .add_filter("All", &["*"]) + .pick_file() + .map(|p| p.to_string_lossy().into_owned()) +} + +/// Apply an action to the config. +pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option { + match action { + ScsiAction::None => None, + ScsiAction::AttachHdd { id, path } => { + cfg.scsi.insert(id, ScsiDeviceConfig { + path, discs: vec![], cdrom: false, overlay: false, scratch: false, size_mb: None, + }); + Some(format!("scsi{id}: HDD attached")) + } + ScsiAction::AttachEmptyCdrom { id } => { + cfg.scsi.insert(id, ScsiDeviceConfig { + path: String::new(), discs: vec![], cdrom: true, + overlay: false, scratch: false, size_mb: None, + }); + Some(format!("scsi{id}: empty CD-ROM drive attached")) + } + ScsiAction::InsertDisc { id, path } => { + if let Some(d) = cfg.scsi.get_mut(&id) { d.path = path; } + Some(format!("scsi{id}: disc inserted")) + } + ScsiAction::Eject { id } => { + if let Some(d) = cfg.scsi.get_mut(&id) { d.path = String::new(); } + Some(format!("scsi{id}: ejected")) + } + ScsiAction::Detach { id } => { + cfg.scsi.remove(&id); + Some(format!("scsi{id}: detached")) + } + ScsiAction::CreateBlank { .. } => { + // App opens the CreateDiskDialog; nothing to apply yet. + None + } + ScsiAction::ToggleOverlay { id } => { + if let Some(d) = cfg.scsi.get_mut(&id) { d.overlay = !d.overlay; } + Some(format!("scsi{id}: overlay toggled")) + } + } +} diff --git a/iris-gui/src/settings.rs b/iris-gui/src/settings.rs new file mode 100644 index 0000000..d007501 --- /dev/null +++ b/iris-gui/src/settings.rs @@ -0,0 +1,104 @@ +use iris::config::MachineConfig; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// GUI-only persisted state. Lives at `~/.config/iris/gui.json`. +/// +/// This is the **system of record** for machines: each named machine is a +/// `MachineConfig` stored here. `iris.toml` is treated as import/export +/// only, for compatibility with the standalone `iris` CLI. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GuiSettings { + /// Window width / height at last close. + #[serde(default)] + pub window_size: Option<[f32; 2]>, + /// egui UI scale (1.0 = default). + #[serde(default = "default_ui_scale")] + pub ui_scale: f32, + /// Was the app left in fullscreen mode at last close? + #[serde(default)] + pub fullscreen: bool, + + /// All saved machines keyed by user-visible name. BTreeMap so menus + /// list them in stable alphabetical order. + #[serde(default)] + pub machines: BTreeMap, + /// Currently-selected machine (key into `machines`). None = no + /// machine loaded yet (first run). + #[serde(default)] + pub active_machine: Option, + + // --- Legacy iris.toml workflow (still supported for users who had it). --- + /// Most-recently-imported iris.toml files (newest first, max ~10). + #[serde(default)] + pub recent_configs: Vec, + /// Last-imported TOML path; one-shot migration source on first launch + /// of the new machine-store world. + #[serde(default)] + pub last_config: Option, +} + +/// Allowed UI-scale range, shared by the View-menu slider, the Ctrl +/-/0 +/// keyboard zoom, and the load-time clamp so a stale persisted value can never +/// put the UI into a state the slider can't represent (which egui would then +/// silently re-clamp to its own bound). +pub const UI_SCALE_MIN: f32 = 1.0; +pub const UI_SCALE_MAX: f32 = 3.0; +pub const UI_SCALE_DEFAULT: f32 = 1.25; + +fn default_ui_scale() -> f32 { UI_SCALE_DEFAULT } + +impl GuiSettings { + pub fn config_path() -> Option { + dirs::config_dir().map(|d| d.join("iris").join("gui.json")) + } + + pub fn load() -> Self { + let Some(path) = Self::config_path() else { return Self::default(); }; + let Ok(text) = std::fs::read_to_string(&path) else { return Self::default(); }; + let mut s: Self = serde_json::from_str(&text).unwrap_or_default(); + // Sanitize a stale/out-of-range persisted scale. A value below the + // minimum is junk left by an older build whose keyboard zoom floored + // at 0.5 (the UI can no longer produce sub-minimum values), so reset + // it to the default rather than honoring it — likewise for a + // non-finite value from a corrupt file. Only the high end is clamped. + s.ui_scale = if !s.ui_scale.is_finite() || s.ui_scale < UI_SCALE_MIN { + UI_SCALE_DEFAULT + } else { + s.ui_scale.min(UI_SCALE_MAX) + }; + s + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::config_path().ok_or("no config dir")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(&path, text).map_err(|e| e.to_string()) + } + + pub fn push_recent(&mut self, path: PathBuf) { + self.recent_configs.retain(|p| p != &path); + self.recent_configs.insert(0, path.clone()); + self.recent_configs.truncate(10); + self.last_config = Some(path); + } + + /// Pick a free name like "indy", "indy-2", "indy-3", … + pub fn unique_name(&self, base: &str) -> String { + if !self.machines.contains_key(base) { return base.to_string(); } + for n in 2..1000 { + let candidate = format!("{base}-{n}"); + if !self.machines.contains_key(&candidate) { return candidate; } + } + format!("{base}-{}", uuid_like()) + } +} + +fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0).to_string() +} diff --git a/iris-gui/src/single_instance.rs b/iris-gui/src/single_instance.rs new file mode 100644 index 0000000..8505611 --- /dev/null +++ b/iris-gui/src/single_instance.rs @@ -0,0 +1,89 @@ +//! Single-instance guard. +//! +//! A hard crash (abort/SIGKILL) skips `on_exit`/`Drop`, but the kernel still +//! reaps the dead process and frees its TCP ports — so a *crashed* instance +//! needs no cleanup. The case that genuinely lingers is a previous `iris-gui` +//! that is still **alive** (hung, or a forgotten copy): it keeps the monitor / +//! serial ports (8888 / 8880 / 8881) bound, which is what produced the early +//! "AddrInUse" failures. +//! +//! On startup we therefore terminate any still-alive previous instance to +//! reclaim those ports, then record our own PID. The pidfile is removed on +//! clean exit; a stale one left by a crash is harmless (its PID is dead, so we +//! skip it and overwrite). Unix only — a no-op elsewhere. + +use std::path::PathBuf; + +fn pidfile_path() -> Option { + dirs::config_dir().map(|d| d.join("iris").join("iris-gui.pid")) +} + +/// Reclaim resources from a previous instance (if any), then claim the lock +/// for this process. Call once at startup, before launching the emulator. +pub fn acquire() { + let Some(path) = pidfile_path() else { return }; + + if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(pid) = contents.trim().parse::() { + #[cfg(unix)] + reclaim_previous(pid); + #[cfg(not(unix))] + let _ = pid; + } + } + + // Record our PID (best effort). Done after reclaim_previous() has confirmed + // the old process is gone, so it can't remove the file we just wrote. + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, std::process::id().to_string()); +} + +/// Remove the pidfile on clean exit. +pub fn release() { + if let Some(path) = pidfile_path() { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(unix)] +fn reclaim_previous(pid: i32) { + use std::process::Command; + use std::time::Duration; + + let me = std::process::id() as i32; + if pid <= 1 || pid == me { + return; + } + + // Only act if the PID is alive AND is actually an iris-gui — the OS may + // have recycled a dead PID to an unrelated program we must not kill. + let comm = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output(); + let is_iris = matches!(&comm, Ok(o) if o.status.success() + && String::from_utf8_lossy(&o.stdout).contains("iris-gui")); + if !is_iris { + return; + } + + log::warn!("terminating previous iris-gui instance (pid {pid}) to reclaim its ports"); + let _ = Command::new("kill").arg(pid.to_string()).status(); // SIGTERM + + // Wait up to ~2s for it to exit (and release its ports) before escalating. + let mut alive = true; + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(100)); + let still = Command::new("kill").args(["-0", &pid.to_string()]).status(); + if matches!(still, Ok(s) if !s.success()) { + alive = false; + break; + } + } + if alive { + let _ = Command::new("kill").args(["-9", &pid.to_string()]).status(); // SIGKILL + // Brief grace for the kernel to tear the process down and free ports. + std::thread::sleep(Duration::from_millis(200)); + } +} diff --git a/rules/gui/01-overview.md b/rules/gui/01-overview.md new file mode 100644 index 0000000..04699b5 --- /dev/null +++ b/rules/gui/01-overview.md @@ -0,0 +1,95 @@ +# iris-gui — overview + +Optional egui-based launcher for iris. Lives as a separate workspace crate +(`iris-gui/`); default `cargo build` does not include it. Build with +`cargo build -p iris-gui --release`. + +## Process / thread model + +- The eframe app owns the **single** `winit::EventLoop` in the process. iris's + own `src/ui.rs` event loop is **not** used by iris-gui — the GUI starts the + emulator in headless mode and (Phase B) renders the REX3 framebuffer into + an egui panel itself. +- A worker thread (`iris-gui/src/handle.rs::worker_loop`) owns the `Machine`. + GUI ↔ worker communication is via `crossbeam_channel` (`Cmd` and `Evt`). + The worker thread has an 8 MB stack to satisfy `Machine::new`'s + `Physical::device_map` allocation, matching `src/main.rs`. +- Settings (recents, window size, ui scale, fullscreen) persist to + `~/.config/iris/gui.json`. Machine configs stay in TOML so they remain + runnable via `iris --config …`. + +## Safe-stop logic (`src/safe_stop.rs`) + +Stopping is "safe" iff any of: +1. PowerOff event observed (IRIX `halt` completed). +2. CPU is sitting at the PROM monitor. +3. Zero dirty COW overlay sectors and no in-flight SCSI writes. + +Otherwise a modal lists the failing condition(s), plus a per-CHD warning when +a SCSI device uses a `.chd` image without `overlay = true` (writes are lost). +Modal offers **Cancel / Send IRIX halt / Force stop**. + +## What the GUI knows about iris + +Only the public API: `MachineConfig`, `Cli`, `Machine::{new, start, stop, +register_system_controller}`. Anything else the GUI needs (status query, +event subscription, framebuffer access) is added as a `pub fn` accessor on +the existing type, never by reaching into private fields. + +## Empty-media CD-ROM (Phase B item #6, landed) + +`ScsiDevice.backend` is `Option`. `None` represents "drive +present, tray empty": INQUIRY still answers, TEST UNIT READY / READ +CAPACITY / READ / READ TOC return `CHECK CONDITION` with sense key +`0x02` (NOT_READY) + ASC `0x3A` (MEDIUM NOT PRESENT). Construct via +`ScsiDevice::new_empty_cdrom()`; mount/swap media with +`Wd33c93a::insert_disc(id, path)`; unload with +`Wd33c93a::eject_to_empty(id)`. In `iris.toml` an empty-tray CD-ROM is +`cdrom = true` with an empty `path` and no `discs` — `MachineConfig:: +validate` accepts this state. + +## Phase B — embedded framebuffer & input (landed) + +The GUI installs an `iris::rex3::Renderer` impl +(`iris-gui/src/framebuffer.rs::CaptureRenderer`) in +`Rex3::renderer` immediately after `Machine::new`, before the CPU +starts. Each `render(buffer, width, height)` call from the REX3 refresh +thread does a stride-aware copy of `width × height` u32 pixels into a +`FrameSink` (parking_lot Mutex of `Frame { width, height, rgba, seq }`). +The main thread reads the sink each egui frame, uploads to a lazily +allocated `egui::TextureHandle`, and renders centered in the central +panel with aspect-preserving fit. + +PS/2 input flows through `iris-gui/src/input.rs::pump`. Modifiers +(shift/ctrl/alt/super) are diffed against the previous frame and +synthesised as `ShiftLeft / ControlLeft / AltLeft / SuperLeft` +press/release events because egui delivers modifiers as a separate +field, not as `Key` events. egui `Key` → `winit::keyboard::KeyCode` +mapping covers letters/digits/punctuation/F-keys/navigation; misses +return `None` and are dropped. Mouse events fire only when the cursor +is inside the framebuffer rect — menu / config clicks don't leak into +the guest. F11 is consumed by the GUI (fullscreen toggle) and never +forwarded. + +`Cmd::SaveState` calls `Machine::save_snapshot` then `Machine::start` +(save_snapshot stops the CPU as part of its work). `Cmd::RestoreState` +calls `Machine::ci_restore`. `Cmd::Screenshot` PNG-encodes the latest +`FrameSink` snapshot via the `png` crate. + +The safe-stop "Send IRIX halt" button TCP-connects to +`127.0.0.1:8881` (iris's standing ttyd1 listener in non-CI mode) and +writes `halt\n`. + +## Phase B follow-ups + +- Embed REX3 framebuffer into an egui panel (add `Rex3::snapshot_rgba()` or + similar, upload to egui texture each frame). +- Wire egui key/pointer events → `Ps2Controller` input. +- Status polling: add `Machine::is_in_prom()`, `Machine::dirty_cow_sectors()`, + `Machine::subscribe_events() -> Receiver`. +- Hook `Machine::ci_save` / `ci_restore` / screenshot to the existing Cmd + variants — currently they `Evt::Error` "not yet wired". +- "Send IRIX halt" should write `halt\n` via the existing serial-send CI + path (currently falls through to force-stop with a toast). +- Replace text-field path entry with `rfd` pickers throughout the config UI + (PROM, NVRAM, SCSI image paths, NFS dir). diff --git a/rules/gui/gui_mouse_integration.md b/rules/gui/gui_mouse_integration.md new file mode 100644 index 0000000..b84a2be --- /dev/null +++ b/rules/gui/gui_mouse_integration.md @@ -0,0 +1,95 @@ +# GUI mouse integration — current approach, and the Snow absolute-mouse pattern + +Status: reference / design analysis. Captures why iris-gui uses pointer +**capture** for the framebuffer, and why the seamless absolute-mouse trick used +by the `snow` Macintosh emulator does **not** port to IRIX without significant +new machinery. Read this before proposing "make the mouse seamless like snow." + +## Current iris-gui approach: capture (grab + hide) + +The guest's PS/2 mouse is **relative** — it reports motion deltas, and IRIX/X11 +draws its own pointer with its own acceleration. A relative guest pointer can +never stay pixel-aligned with a *visible* host cursor; the two drift, and the +host→guest sensitivity is wrong on top of that. + +So iris-gui uses the standard emulator model (mirroring `src/ui.rs`): + +- **Click the framebuffer to capture.** On capture we hide the host cursor and + lock it in place (`egui::ViewportCommand::CursorGrab(CursorGrab::Locked)` + + `CursorVisible(false)`). Only the guest's own pointer is visible, so there is + nothing to misalign. +- **Motion uses raw deltas.** eframe forwards `winit DeviceEvent::MouseMotion` + as `egui::Event::MouseMoved(delta)` regardless of grab mode + (`eframe .../glow_integration.rs` → `egui_winit::on_mouse_motion`). We read + those deltas and feed them straight to the PS/2 controller — natural 1:1 + feel, no scaling, no warp-to-center, no edge-piling. +- **Ctrl+Alt+Esc (or focus loss) releases** — Alt is the Option key on macOS; + a chord so plain Esc still reaches the guest. Input is gated on capture: while captured, + keyboard + mouse go to the guest; while not, they stay with egui (so menu + clicks and config-side-panel typing don't leak into IRIX). + +Implementation: `iris-gui/src/input.rs` (`pump`, `release_capture`, +`force_release`); capture is also force-released when the emulator stops so the +host cursor can't get stuck hidden. + +> Note: iris's `mouseabs` cargo feature is **misnamed** — it is still grab + +> warp-to-center + relative deltas (`src/ui.rs:532`), *not* absolute +> positioning. There is no hidden absolute backend to tap. + +## What `snow` does (the absolute pattern) + +`snow` (sibling repo `../snow`, a classic Macintosh emulator) gets seamless, +capture-free, 1:1 mouse alignment via an **absolute** mode that bypasses the +emulated mouse hardware entirely: + +- `mouse_update_abs(x, y)` writes the host cursor position directly into classic + Mac OS **low-memory globals**: `MTemp` (MouseTemp) and `RawMouse`, then sets + the `CrsrNew` flag. See `core/src/mac/compact/bus.rs:476` (and the Mac II + variant in `core/src/mac/macii/bus.rs`). +- Mac OS polls those globals every tick and "jumps" its cursor to the new + position. The ADB mouse (`core/src/mac/adb/mouse.rs`) stays relative but is + sidestepped in absolute mode. +- The frontend exposes a `MouseMode { Absolute, RelativeHw, Disabled }` seam and + calls `update_mouse(abs_p, rel_p)` (`frontend_egui/src/emulator.rs:434`), + dispatching `MouseUpdateAbsolute { x, y }` vs `MouseUpdateRelative { .. }`. + +It works because **classic Mac OS exposes a stable, documented, memory-mapped +cursor position you may overwrite, and cooperatively re-reads it.** + +## Why it does not port to IRIS/IRIX cheaply + +IRIX has no equivalent of that mechanism: + +- **No fixed mouse globals.** IRIX is Unix + X11. Pointer position lives in the + X server's dynamically-allocated state at addresses that vary per boot/build — + there is no constant to poke like `MTemp`. +- **The cursor is a hardware sprite** programmed by the X server through + REX3/VC2/RAMDAC, and X derives pointer position from *relative* input-device + events plus its own acceleration curve. +- **No "set absolute pointer via memory" convention.** X's supported absolute + paths are the input protocol (absolute valuators / XInput) or + `XWarpPointer`/XTEST — none of which the emulated SGI PS/2-style mouse exposes. + +## What a Snow-like absolute mode would actually require here + +One of: + +1. **Emulate an absolute pointing device** IRIX already has a driver for (e.g. a + tablet/touch valuator on the input bus) and feed normalized coordinates — + new device emulation, depends on IRIX driver support. +2. **A guest-side agent** that calls `XWarpPointer` from coordinates passed over + a channel — requires installing software inside the guest. +3. **A feedback hack**: locate the X server's pointer coordinates in guest RAM + at runtime and synthesize relative deltas toward the host position — + fragile, version-specific, not "without altering much." + +None of these is a small port. + +## Recommendation + +Capture is the correct, standard approach for an X11 guest — it is what +SGI/Unix emulators do, and what snow itself falls back to (`RelativeHw`). The +one piece genuinely worth borrowing from snow is its clean frontend seam: a +`MouseMode` enum + `update_mouse(abs, rel)`. Adopting that abstraction now +(even with only relative/capture wired up) would make options 1 or 2 drop-in +later, without committing to the absolute backend today. diff --git a/scripts/add-transparency.sh b/scripts/add-transparency.sh new file mode 100755 index 0000000..a72d501 --- /dev/null +++ b/scripts/add-transparency.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# scripts/add-transparency.sh +# Makes white/light background transparent in the original iris-gui icon. +# Only needed if icon-original.png ships with an opaque background; the bundled +# iris icon already has an alpha channel, so this is here for re-sourcing. + +set -e + +ORIGINAL="iris-gui/assets/icon-original.png" +OUTPUT="iris-gui/assets/icon-original-transparent.png" + +# Check if ImageMagick is installed +if command -v magick &> /dev/null; then + MAGICK_CMD="magick" +elif command -v convert &> /dev/null; then + MAGICK_CMD="convert" +else + echo "Error: ImageMagick is not installed." + exit 1 +fi + +# Check if original exists +if [ ! -f "$ORIGINAL" ]; then + echo "Error: $ORIGINAL not found" + exit 1 +fi + +echo "Adding transparency to icon..." +echo "" +echo "This will make white/light backgrounds transparent." +echo "Adjust the -fuzz percentage if needed (higher = more aggressive)" +echo "" + +# Make white background transparent +# -fuzz 10% allows slight variations in white color +# Adjust this value if your background isn't pure white +$MAGICK_CMD "$ORIGINAL" -fuzz 10% -transparent white "$OUTPUT" + +echo "✓ Created transparent version: $OUTPUT" +echo "" +echo "Check the output file. If it looks good:" +echo " mv iris-gui/assets/icon-original-transparent.png iris-gui/assets/icon-original.png" +echo " ./scripts/generate-icon.sh" +echo "" +echo "If too much was made transparent, try a lower -fuzz value (e.g., 5%)" +echo "If not enough, try a higher value (e.g., 15% or 20%)" diff --git a/scripts/generate-icon.sh b/scripts/generate-icon.sh new file mode 100755 index 0000000..9e2b641 --- /dev/null +++ b/scripts/generate-icon.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# scripts/generate-icon.sh +# Converts iris-gui/assets/icon-original.png to all required formats for the +# optional iris-gui front-end (window/app icon) and cross-platform packaging. +# +# Run from the repo root: ./scripts/generate-icon.sh +# Generated files are committed; iris-gui includes icon-256.png at compile time +# (see iris-gui/src/main.rs). This is intentionally manual — iris-gui is an +# optional feature and nothing in the normal build invokes this script. + +set -e + +ORIGINAL="iris-gui/assets/icon-original.png" +ASSETS_DIR="iris-gui/assets/icons" +APP_NAME="iris-gui" + +# Check if ImageMagick is installed +if command -v magick &> /dev/null; then + MAGICK_CMD="magick" +elif command -v convert &> /dev/null; then + MAGICK_CMD="convert" +else + echo "Error: ImageMagick is not installed." + echo "Install it with:" + echo " macOS: brew install imagemagick" + echo " Linux: sudo apt-get install imagemagick" + exit 1 +fi + +# Check if original icon exists +if [ ! -f "$ORIGINAL" ]; then + echo "Error: $ORIGINAL not found" + exit 1 +fi + +# Check if original has alpha channel +echo "Checking original icon..." +if $MAGICK_CMD identify -format "%[channels]" "$ORIGINAL" | grep -q "a"; then + echo "✓ Original icon has alpha channel (transparency)" +else + echo "⚠ Warning: Original icon does not have alpha channel" + echo " The icon may not be transparent" +fi + +# Create assets directory +mkdir -p "$ASSETS_DIR" + +echo "Converting $ORIGINAL to multiple formats..." + +# Transparent margin baked into every rendered size. macOS lays app icons out +# on a grid where the artwork fills ~80% of the canvas (≈10% margin per side); +# a full-bleed icon renders edge-to-edge and looks oversized next to others in +# the Dock. CONTENT_PCT is the artwork's share of each side. +CONTENT_PCT=80 + +# render — scale the artwork to CONTENT_PCT% of and +# center it on a fully transparent x canvas (RGBA). +render() { + local size=$1 out=$2 + local inner=$(( size * CONTENT_PCT / 100 )) + $MAGICK_CMD "$ORIGINAL" -background none -alpha on \ + -resize ${inner}x${inner} -gravity center -extent ${size}x${size} \ + PNG32:"$out" +} + +# Generate PNG icons at various sizes (preserving transparency) +SIZES=(16 32 48 64 128 256 512 1024) +for size in "${SIZES[@]}"; do + echo "Creating ${size}x${size} PNG..." + render "$size" "$ASSETS_DIR/icon-${size}.png" +done + +# Generate Windows ICO file (multi-size with transparency), reusing the +# already-rendered, margin-padded PNGs. +echo "Creating Windows ICO file..." +$MAGICK_CMD \ + "$ASSETS_DIR/icon-16.png" \ + "$ASSETS_DIR/icon-32.png" \ + "$ASSETS_DIR/icon-48.png" \ + "$ASSETS_DIR/icon-64.png" \ + "$ASSETS_DIR/icon-128.png" \ + "$ASSETS_DIR/icon-256.png" \ + -colors 256 "$ASSETS_DIR/icon.ico" + +# Generate macOS ICNS file (requires additional tools on macOS) +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "Creating macOS ICNS file..." + + # Create iconset directory + ICONSET="$ASSETS_DIR/icon.iconset" + mkdir -p "$ICONSET" + + # Generate all required sizes for ICNS (with transparency + margin) + render 16 "$ICONSET/icon_16x16.png" + render 32 "$ICONSET/icon_16x16@2x.png" + render 32 "$ICONSET/icon_32x32.png" + render 64 "$ICONSET/icon_32x32@2x.png" + render 128 "$ICONSET/icon_128x128.png" + render 256 "$ICONSET/icon_128x128@2x.png" + render 256 "$ICONSET/icon_256x256.png" + render 512 "$ICONSET/icon_256x256@2x.png" + render 512 "$ICONSET/icon_512x512.png" + render 1024 "$ICONSET/icon_512x512@2x.png" + + # Convert to ICNS + iconutil -c icns "$ICONSET" + + # Clean up + rm -rf "$ICONSET" +else + echo "Skipping ICNS generation (macOS only)" + echo "Note: PNG icons are used for macOS when ICNS is unavailable" +fi + +# Create a simple AppImage-ready icon structure +echo "Creating AppImage icon structure..." +mkdir -p "$ASSETS_DIR/hicolor/256x256/apps" +cp "$ASSETS_DIR/icon-256.png" "$ASSETS_DIR/hicolor/256x256/apps/$APP_NAME.png" + +# Copy main icon for easy reference +cp "$ASSETS_DIR/icon-256.png" "$ASSETS_DIR/icon.png" + +echo "" +echo "✓ Icon conversion complete!" +echo "" +echo "Generated files:" +ls -lh "$ASSETS_DIR" +echo "" +echo "Icon files are ready for:" +echo " • iris-gui window icon: $ASSETS_DIR/icon-256.png (compiled in via include_bytes!)" +echo " • Windows: $ASSETS_DIR/icon.ico" +echo " • macOS: $ASSETS_DIR/icon.png (or icon.icns if on macOS)" +echo " • Linux AppImage: $ASSETS_DIR/hicolor/256x256/apps/$APP_NAME.png" diff --git a/src/ci.rs b/src/ci.rs index 635a2d7..a4a6099 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -260,7 +260,12 @@ fn cmd_quit() -> Response { if let Some(p) = SOCKET_PATH.lock().take() { let _ = std::fs::remove_file(&p); } - std::process::exit(0); + // Same escape hatch as the PowerOff handler: library hosts set + // IRIS_NO_EXIT_ON_POWEROFF=1 so a `quit` over the CI socket does + // not kill the embedder. + if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_none() { + std::process::exit(0); + } }); Response::ok() } diff --git a/src/config.rs b/src/config.rs index 558f31a..005c6fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -129,10 +129,17 @@ pub enum VinoSource { /// Solid black field. Useful when you want IRIX video drivers to attach /// but don't want any host camera permission prompt or test pattern. Black, + /// Video-In disabled: VINO stays memory-mapped (IRIX can still probe it) + /// but no video source is installed and the DMA pump thread is never + /// started. Use this to skip Video-In entirely. + Off, } impl Default for VinoSource { - fn default() -> Self { VinoSource::TestPattern } + // Off by default: most users don't need IndyCam, and this avoids a host + // camera permission prompt, the test-pattern source, and VINO's DMA pump + // thread. Set a source explicitly (`[vino] source = "..."`) to enable it. + fn default() -> Self { VinoSource::Off } } /// Broadcast video standard the source emits. @@ -164,7 +171,43 @@ pub struct VinoConfig { pub camera_index: u32, } +/// (De)serialize the `scsi` map through string keys. TOML (and the `toml` +/// crate's serializer) requires map keys to be strings, but the map is keyed +/// by `u8`, so `toml::to_string` would fail with "map key was not a string". +/// JSON is unaffected (it already stringifies map keys); this just makes the +/// representation explicit and symmetric so iris.toml export round-trips. +mod scsi_keys { + use super::ScsiDeviceConfig; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::{BTreeMap, HashMap}; + + pub fn serialize( + map: &HashMap, + ser: S, + ) -> Result { + // BTreeMap → stable, ID-sorted output. + map.iter() + .map(|(k, v)| (k.to_string(), v)) + .collect::>() + .serialize(ser) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + HashMap::::deserialize(de)? + .into_iter() + .map(|(k, v)| k.parse::().map(|id| (id, v)).map_err(serde::de::Error::custom)) + .collect() + } +} + /// Top-level machine configuration. +/// +/// Field order matters for TOML export: the `toml` serializer requires every +/// scalar/inline-value field to be emitted before any table or array-of-table +/// field, so all scalars are declared first and the table-valued fields +/// (`scsi`, `nfs`, `port_forward`, `vino`) come last. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MachineConfig { /// Path to the PROM ROM image. @@ -183,22 +226,10 @@ pub struct MachineConfig { #[serde(default = "default_banks")] pub banks: [u32; 4], - /// SCSI devices keyed by ID 1–7. Missing IDs are not attached. - #[serde(default = "default_scsi")] - pub scsi: std::collections::HashMap, - /// Window scale factor (1 = native, 2 = 2× for HiDPI/4K). CLI --2x overrides this. #[serde(default = "default_scale")] pub scale: u32, - /// NFS share configuration. If present, unfsd is started and NFS is available inside the VM. - #[serde(default)] - pub nfs: Option, - - /// Port forwarding rules (host port → guest port). - #[serde(default)] - pub port_forward: Vec, - /// Run without graphics (no window, no REX3). Use no_audio to also disable HAL2. /// Useful for headless/server/CI environments. #[serde(default)] @@ -209,13 +240,13 @@ pub struct MachineConfig { pub no_audio: bool, /// If Some(port), start the GDB RSP stub on that TCP port. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub gdb_port: Option, /// NAT subnet in CIDR notation (e.g. "192.168.5.0/24"). /// The gateway gets host .1 and the guest (IRIX) gets host .2. /// Defaults to "192.168.0.0/24" if not set. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub nat_subnet: Option, /// CI mode: opens a control socket for automation, applies speed-favoring @@ -235,9 +266,23 @@ pub struct MachineConfig { /// Optional file path that will receive every byte emitted on ttyd1 /// (the IRIX serial console) in `--ci` mode. Append-only. Useful for /// keeping a continuously-updated transcript of the install or test run. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub serial_log: Option, + // --- table / array-of-table fields: must be emitted after all scalars --- + + /// SCSI devices keyed by ID 1–7. Missing IDs are not attached. + #[serde(default = "default_scsi", with = "scsi_keys")] + pub scsi: std::collections::HashMap, + + /// NFS share configuration. If present, unfsd is started and NFS is available inside the VM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nfs: Option, + + /// Port forwarding rules (host port → guest port). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub port_forward: Vec, + /// VINO video-in configuration (IndyCam emulation source). #[serde(default)] pub vino: VinoConfig, @@ -341,9 +386,10 @@ impl MachineConfig { if *id == 0 || *id > 7 { return Err(format!("SCSI ID {} is out of range (1–7)", id)); } - if dev.cdrom && dev.path.is_empty() && dev.discs.is_empty() { - return Err(format!("SCSI ID {} is a CD-ROM but has no disc", id)); - } + // CD-ROM with empty path + no changer entries = drive present, no + // media loaded. This is a valid runtime state (see + // Wd33c93a::add_device empty-CD-ROM path / insert_disc). + let _ = dev; // explicitly keep the binding for future checks } Ok(()) } @@ -611,3 +657,23 @@ pub fn parse_nat_subnet(cidr: &str) -> Result<(std::net::Ipv4Addr, std::net::Ipv let client_ip = std::net::Ipv4Addr::from(network + 2); Ok((gateway_ip, client_ip, netmask)) } + +#[cfg(test)] +mod export_tests { + use super::*; + + #[test] + fn toml_export_roundtrips() { + let mut cfg = MachineConfig::default(); + cfg.scsi.insert(4, ScsiDeviceConfig { + path: "/abs/cd.chd".into(), discs: vec![], cdrom: true, + overlay: false, scratch: false, size_mb: None, + }); + let s = toml::to_string_pretty(&cfg).expect("serialize"); + let back: MachineConfig = toml::from_str(&s).expect("deserialize"); + assert_eq!(back.scsi.len(), cfg.scsi.len()); + assert_eq!(back.scsi[&1].path, cfg.scsi[&1].path); + assert_eq!(back.scsi[&4].cdrom, true); + println!("--- exported toml ---\n{s}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index c569c6c..9d1fee0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,19 @@ #![allow(dead_code, unused_variables, unused_imports)] + +/// Compile-time feature flags exposed for tooling (e.g. iris-gui) so it can +/// surface "CHD support required" / "camera support required" hints without +/// duplicating the cargo feature set. +pub mod build_features { + pub const CHD: bool = cfg!(feature = "chd"); + pub const CAMERA: bool = cfg!(feature = "camera"); + pub const JIT: bool = cfg!(feature = "jit"); + pub const REX_JIT: bool = cfg!(feature = "rex-jit"); + /// Lightning build strips breakpoint checks and the traceback buffer + /// from the MIPS executor hot path. Interactive debugging (GDB stub, + /// monitor breakpoints) is non-functional in this build. + pub const LIGHTNING: bool = cfg!(feature = "lightning"); +} + pub mod config; pub mod traits; #[macro_use] diff --git a/src/machine.rs b/src/machine.rs index 334abfb..6bb939d 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -379,32 +379,36 @@ impl Machine { crate::config::VinoStandard::Ntsc => crate::video_source::VideoStandard::Ntsc, crate::config::VinoStandard::Pal => crate::video_source::VideoStandard::Pal, }; - let source: Arc = match cfg.vino.source { + let source: Option> = match cfg.vino.source { crate::config::VinoSource::Camera => { #[cfg(feature = "camera")] { let idx = cfg.vino.camera_index; match crate::camera::CameraSource::new_with_index(standard, idx) { - Ok(c) => Arc::new(c), + Ok(c) => Some(Arc::new(c)), Err(e) => { eprintln!("VINO: camera {} unavailable ({}); using black source", idx, e); - Arc::new(crate::video_source::BlackSource::new(standard)) + Some(Arc::new(crate::video_source::BlackSource::new(standard))) } } } #[cfg(not(feature = "camera"))] { eprintln!("VINO: source=\"camera\" set but iris was built without --features camera; using test pattern"); - Arc::new(crate::video_source::TestPatternSource::new(standard)) + Some(Arc::new(crate::video_source::TestPatternSource::new(standard))) } } crate::config::VinoSource::TestPattern => - Arc::new(crate::video_source::TestPatternSource::new(standard)), + Some(Arc::new(crate::video_source::TestPatternSource::new(standard))), crate::config::VinoSource::Black => - Arc::new(crate::video_source::BlackSource::new(standard)), + Some(Arc::new(crate::video_source::BlackSource::new(standard))), + // Video-In disabled: no source, no DMA thread. VINO stays mapped. + crate::config::VinoSource::Off => None, }; - phys.vino.set_source(source); - phys.vino.start(); + if let Some(source) = source { + phys.vino.set_source(source); + phys.vino.start(); + } // 5. CPU config + TLB + Executor let cfg = MipsCpuConfig::indy(); @@ -581,8 +585,13 @@ impl Machine { MachineEvent::PowerOff => { println!("Machine: soft power-off"); machine.stop(); + // Hosts that embed iris as a library (e.g. iris-gui) + // set IRIS_NO_EXIT_ON_POWEROFF=1 so a guest halt + // does not kill the host process. #[cfg(not(feature = "developer"))] - std::process::exit(0); + if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_none() { + std::process::exit(0); + } } } Ok(()) diff --git a/src/monitor.rs b/src/monitor.rs index 978b6da..45493a7 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -22,7 +22,18 @@ impl Monitor { pub fn start_server(self: Arc, addr: String) { thread::spawn(move || { - let listener = TcpListener::bind(&addr).expect("Failed to bind monitor port"); + // Fail soft (rather than panic-aborting the whole process) if the + // port can't be bound — most commonly because a previous machine + // instance's monitor thread from the same GUI session is still + // holding it (Machine::stop does not tear the monitor down). The + // machine still boots; the monitor console is just unavailable. + let listener = match TcpListener::bind(&addr) { + Ok(l) => l, + Err(e) => { + log::warn!("monitor disabled: failed to bind {addr}: {e}"); + return; + } + }; println!("Monitor listening on {}", addr); for stream in listener.incoming() { match stream { diff --git a/src/rex3_jit/mod.rs b/src/rex3_jit/mod.rs index 9ac45ca..f6b5539 100644 --- a/src/rex3_jit/mod.rs +++ b/src/rex3_jit/mod.rs @@ -116,8 +116,14 @@ impl RexJit { cache.len() }; store_clone.queued.write().unwrap().remove(&(dm0, dm1)); - eprintln!("REX JIT: compiled dm0={dm0:#010x} dm1={dm1:#010x} \ - ({code_bytes}B, total: {count})"); + // Per-shader success is informational, not an error. + // Route it through the gated dev log (enable with + // IRIS_DEBUG_LOG=rex3 or the monitor `log rex3` command) + // instead of spamming stderr on every unique draw mode. + crate::dlog!( + crate::devlog::LogModule::Rex3, + "REX JIT: compiled dm0={dm0:#010x} dm1={dm1:#010x} ({code_bytes}B, total: {count})" + ); } None => { // Compilation failed or not JIT-able; mark as permanently diff --git a/src/scsi.rs b/src/scsi.rs index 40953cf..739eb2e 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -133,10 +133,13 @@ impl DiskBackend { } pub struct ScsiDevice { - backend: DiskBackend, + /// None = no media loaded (CD-ROM drive present but tray is empty). + /// HDDs are never None in practice. + backend: Option, + /// Capacity in bytes of the loaded media. 0 when `backend` is None. size: u64, is_cdrom: bool, - /// Path of the currently mounted image. + /// Path of the currently mounted image. Empty string when no media. filename: String, /// Full disc list for CD-ROM changers. Index 0 is always the active disc. /// For HDDs this is empty (unused). @@ -158,7 +161,7 @@ const SCSI_BUFFER_SIZE: usize = 0x4000; // 16KB (16384 bytes) impl ScsiDevice { pub fn new(backend: DiskBackend, size: u64, is_cdrom: bool, filename: String, discs: Vec) -> Self { Self { - backend, + backend: Some(backend), size, is_cdrom, filename, @@ -173,62 +176,94 @@ impl ScsiDevice { } } + /// Construct an empty CD-ROM drive — drive present, no media inserted. + /// IRIX will see the drive in `hinv` but `TEST UNIT READY` reports + /// MEDIUM NOT PRESENT. + pub fn new_empty_cdrom() -> Self { + Self { + backend: None, + size: 0, + is_cdrom: true, + filename: String::new(), + discs: vec![], + buffer: vec![0u8; SCSI_BUFFER_SIZE], + pending_sense: [0u8; 18], + unit_attention: false, + phys_block_size: 2048, + logical_block_size: 2048, + } + } + + /// Whether physical media is loaded. For HDDs always true; for CD-ROMs + /// false when the tray is empty. + pub fn has_media(&self) -> bool { self.backend.is_some() } + + /// Mount media on a previously-empty CD-ROM, or swap the disc on a + /// loaded one. Sets `unit_attention` so the guest re-reads capacity. + pub fn insert_media(&mut self, path: &str) -> io::Result<()> { + let f = OpenOptions::new().read(true).open(path)?; + let size = f.metadata()?.len(); + self.backend = Some(DiskBackend::Direct(f)); + self.size = size; + self.filename = path.to_string(); + self.unit_attention = true; + Ok(()) + } + + /// Unload media from a CD-ROM (tray empty). + pub fn unload_media(&mut self) { + self.backend = None; + self.size = 0; + self.filename = String::new(); + self.unit_attention = true; + } + /// Commit the COW overlay to the base image. No-op if not using COW. - /// Returns the number of sectors committed, or 0 if direct mode. + /// Returns the number of sectors committed, or 0 if direct/no media. pub fn cow_commit(&mut self) -> io::Result { match &mut self.backend { - DiskBackend::Cow(cow) => cow.commit(), - DiskBackend::Direct(_) => Ok(0), - #[cfg(feature = "chd")] - DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(0), + Some(DiskBackend::Cow(cow)) => cow.commit(), + _ => Ok(0), } } /// Reset the COW overlay (discard all writes). No-op if not using COW. pub fn cow_reset(&mut self) -> io::Result<()> { match &mut self.backend { - DiskBackend::Cow(cow) => cow.reset_overlay(), - DiskBackend::Direct(_) => Ok(()), - #[cfg(feature = "chd")] - DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), + Some(DiskBackend::Cow(cow)) => cow.reset_overlay(), + _ => Ok(()), } } /// Copy the COW overlay into `dest` and return its dirty sector set. - /// Direct-mode devices return an empty list and create no file. + /// Direct-mode / no-media devices return an empty list and create no file. pub fn cow_export(&mut self, dest: &std::path::Path) -> io::Result> { match &mut self.backend { - DiskBackend::Cow(cow) => cow.export_overlay(dest), - DiskBackend::Direct(_) => Ok(Vec::new()), - #[cfg(feature = "chd")] - DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(Vec::new()), + Some(DiskBackend::Cow(cow)) => cow.export_overlay(dest), + _ => Ok(Vec::new()), } } /// Replace the COW overlay with the contents of `source` and adopt - /// `dirty` as the dirty sector set. No-op on direct-mode devices. + /// `dirty` as the dirty sector set. No-op on non-COW / no-media devices. pub fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { match &mut self.backend { - DiskBackend::Cow(cow) => cow.import_overlay(source, dirty), - DiskBackend::Direct(_) => Ok(()), - #[cfg(feature = "chd")] - DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), + Some(DiskBackend::Cow(cow)) => cow.import_overlay(source, dirty), + _ => Ok(()), } } - /// Number of dirty sectors in the COW overlay, or 0 if direct mode. + /// Number of dirty sectors in the COW overlay, or 0 if direct/no media. pub fn cow_dirty_count(&self) -> usize { match &self.backend { - DiskBackend::Cow(cow) => cow.dirty_count(), - DiskBackend::Direct(_) => 0, - #[cfg(feature = "chd")] - DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => 0, + Some(DiskBackend::Cow(cow)) => cow.dirty_count(), + _ => 0, } } /// Whether this device is using COW overlay mode. pub fn is_cow(&self) -> bool { - matches!(&self.backend, DiskBackend::Cow(_)) + matches!(&self.backend, Some(DiskBackend::Cow(_))) } /// Advance to the next disc in the list (wraps around). @@ -246,7 +281,7 @@ impl ScsiDevice { match OpenOptions::new().read(true).open(&next_path) { Ok(f) => { let size = f.metadata().map(|m| m.len()).unwrap_or(0); - self.backend = DiskBackend::Direct(f); + self.backend = Some(DiskBackend::Direct(f)); self.size = size; // phys_block_size never changes — CD-ROM physical sectors are always 2048. // Do NOT reset logical_block_size — MODE SELECT is a controller setting @@ -288,7 +323,7 @@ impl ScsiDevice { let f = OpenOptions::new().read(true).open(&path) .map_err(|e| format!("could not open {}: {}", path, e))?; let size = f.metadata().map(|m| m.len()).unwrap_or(0); - self.backend = DiskBackend::Direct(f); + self.backend = Some(DiskBackend::Direct(f)); self.size = size; // phys/logical block sizes persist across disc changes (controller // settings), exactly as in eject_next. @@ -429,6 +464,10 @@ impl ScsiDevice { // Sense key 0x06 UNIT_ATTENTION, ASC 0x28 "Not Ready to Ready Transition / Medium Changed" return Ok(self.check_condition(0x06, 0x28, 0x00)); } + if self.backend.is_none() { + // Sense key 0x02 NOT_READY, ASC 0x3A "Medium not present" + return Ok(self.check_condition(0x02, 0x3A, 0x00)); + } Ok(ScsiResponse { status: 0x00, data: vec![], @@ -484,7 +523,10 @@ impl ScsiDevice { }) } - fn exec_read_capacity_10(&self, _cdb: &[u8]) -> Result { + fn exec_read_capacity_10(&mut self, _cdb: &[u8]) -> Result { + if self.backend.is_none() { + return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT + } let block_size = self.logical_block_size as u32; let last_lba = (self.size / self.logical_block_size).saturating_sub(1) as u32; //eprintln!("SCSI READ CAPACITY: block_size={} last_lba={}", block_size, last_lba); @@ -512,12 +554,15 @@ impl ScsiDevice { } fn perform_read(&mut self, lba: u64, count: usize) -> Result { + let Some(backend) = self.backend.as_mut() else { + return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT + }; // Check LBA bounds before attempting I/O let last_lba = self.size / self.logical_block_size; if count > 0 && (lba >= last_lba || lba + count as u64 > last_lba) { return Ok(self.check_condition(0x05, 0x21, 0x00)); // Illegal Request: LBA Out of Range } - let data = self.backend.read_blocks(lba, count, self.logical_block_size)?; + let data = backend.read_blocks(lba, count, self.logical_block_size)?; let expected = count as u64 * self.logical_block_size; if data.len() as u64 != expected { eprintln!( @@ -567,7 +612,10 @@ impl ScsiDevice { } // Writes always go through as 512-byte sectors (HDD path only, phys=logical=512) - self.backend.write_sectors(lba, data)?; + let Some(backend) = self.backend.as_mut() else { + return Ok(self.check_condition(0x02, 0x3A, 0x00)); + }; + backend.write_sectors(lba, data)?; Ok(ScsiResponse { status: 0x00, @@ -874,6 +922,9 @@ impl ScsiDevice { } fn exec_read_toc_pma_atip(&mut self, cdb: &[u8]) -> Result { + if self.backend.is_none() { + return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT + } let msf = (cdb[1] & 0x02) != 0; let format = cdb[2] & 0x0F; diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index 90ffdf5..054a66b 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -292,6 +292,18 @@ impl Wd33c93a { use crate::cow_disk::CowDisk; use crate::scsi::DiskBackend; + // Empty CD-ROM (drive present, tray empty). Stored backend=None so + // TEST UNIT READY / READ CAPACITY / READ return MEDIUM NOT PRESENT, + // while INQUIRY still advertises the drive. Use insert_disc() to + // mount media later. + if is_cdrom && path.is_empty() { + let mut state = self.state.lock(); + if id < 8 { + state.devices[id] = Some(crate::scsi::ScsiDevice::new_empty_cdrom()); + } + return Ok(()); + } + #[cfg(feature = "chd")] let is_chd_path = crate::chd_disk::is_chd(path); #[cfg(not(feature = "chd"))] @@ -349,6 +361,29 @@ impl Wd33c93a { Ok(()) } + /// Mount media on a CD-ROM device (newly inserts or swaps existing). + /// Errors if the slot is empty, is not a CD-ROM, or the file can't open. + pub fn insert_disc(&self, id: usize, path: &str) -> Result<(), String> { + let mut state = self.state.lock(); + match state.devices.get_mut(id).and_then(|d| d.as_mut()) { + None => Err(format!("No device at SCSI ID {}", id)), + Some(dev) if !dev.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), + Some(dev) => dev.insert_media(path).map_err(|e| format!("insert_disc: {}", e)), + } + } + + /// Unload media from a CD-ROM (leave the drive present but empty). + /// Use this when you want "drive present, no disc" rather than the + /// changer-cycle behaviour of `eject_disc`. + pub fn eject_to_empty(&self, id: usize) -> Result<(), String> { + let mut state = self.state.lock(); + match state.devices.get_mut(id).and_then(|d| d.as_mut()) { + None => Err(format!("No device at SCSI ID {}", id)), + Some(dev) if !dev.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), + Some(dev) => { dev.unload_media(); Ok(()) } + } + } + /// Eject the current disc on a CD-ROM device and advance to the next in /// the changer list. Returns the new active path, or an error string. pub fn eject_disc(&self, id: usize) -> Result { diff --git a/src/z85c30.rs b/src/z85c30.rs index 7404335..6cd0f4f 100644 --- a/src/z85c30.rs +++ b/src/z85c30.rs @@ -440,13 +440,27 @@ struct TcpSocketBackend { } impl TcpSocketBackend { - fn new(addr: A) -> Self { - let listener = TcpListener::bind(addr).expect("Failed to bind serial TCP socket"); - listener.set_nonblocking(true).expect("Failed to set nonblocking"); - Self { + /// Bind a TCP serial backend. Returns `None` (rather than panicking) if the + /// port can't be bound — most commonly because a stale emulator process from + /// an earlier crash is still holding it. Callers fall back to a NullBackend + /// so the machine still boots; that serial channel is simply unavailable + /// until the port frees. + fn new(addr: A) -> Option { + let listener = match TcpListener::bind(addr) { + Ok(l) => l, + Err(e) => { + log::warn!("serial TCP backend disabled: failed to bind socket: {e}"); + return None; + } + }; + if let Err(e) = listener.set_nonblocking(true) { + log::warn!("serial TCP backend disabled: failed to set nonblocking: {e}"); + return None; + } + Some(Self { listener, conn: Mutex::new(None), - } + }) } } @@ -561,9 +575,15 @@ impl Z85c30 { let ip_b = Arc::new(AtomicU8::new(0)); let (backend_a, backend_b): (Arc, Arc) = if bind_tcp { + let tcp_or_null = |addr| -> Arc { + match TcpSocketBackend::new(addr) { + Some(b) => Arc::new(b), + None => Arc::new(NullBackend), + } + }; ( - Arc::new(TcpSocketBackend::new("127.0.0.1:8880")), - Arc::new(TcpSocketBackend::new("127.0.0.1:8881")), + tcp_or_null("127.0.0.1:8880"), + tcp_or_null("127.0.0.1:8881"), ) } else { (Arc::new(NullBackend), Arc::new(NullBackend))