diff --git a/Cargo.toml b/Cargo.toml index e33a952..0a512ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,7 @@ camera = ["dep:nokhwa", "dep:v4l"] # Off by default — enable with `cargo build --features pcap`. Select at runtime # with `[network] mode = "pcap"` in iris.toml. pcap = ["dep:pcap"] -# N64 development board IPC bridge: shared-memory RAMROM + cross-process semaphores. +# N64 development board (Ultra64) — GIO slot 0 + shm IPC. ultra64 = ["dep:shared_memory", "dep:raw_sync"] [dependencies] @@ -114,6 +114,7 @@ libchdman-rs = { version = "0.287.0-l7", features = ["prebuilt"], optional = tru pcap = { version = "2", optional = true } shared_memory = { version = "0.12", optional = true } raw_sync = { version = "0.1", optional = true } +windows-sys = { version = "0.52", features = ["Win32_System_Threading"] } [target.'cfg(target_os = "macos")'.dependencies] nokhwa = { version = "0.10", features = ["input-avfoundation"], optional = true } diff --git a/HELP.md b/HELP.md index 2fb619e..f5a578e 100644 --- a/HELP.md +++ b/HELP.md @@ -15,6 +15,40 @@ Serial ports are on ports 8880 and 8881 (connect to 8881 for IRIX serial term) --- +## Performance vs inventory (MHz vs MIPS) + +IRIX **System Manager** and `hinv` report a **CPU MHz** line (e.g. 166 MHz). That +is **guest inventory** from the PROM and kernel — not how fast your PC is +emulating the Indy. + +The **iris-gui status bar MIPS** figure (or the CLI window title `MIPS` readout) +is **real throughput**: MIPS instructions executed per wall-clock second on the +host. Enable the JIT stack (`IRIS_JIT=1`, `--features jit,rex-jit`) for higher +MIPS; the hinv MHz string stays the same. + +The status bar **Hz** value is the CP0 Compare tick rate (kernel scheduler +cadence), not CPU MHz. + +--- + +## RAM banks (config vs guest) + +`banks` in `iris.toml` / iris-gui is applied only when you **Start** the VM. +Changing RAM while IRIX is running updates the config but not the live guest — +**Stop → change banks → Start**. + +| Layout | `banks` | Typical guest RAM | +|--------|---------|-------------------| +| Authentic Indy max | `[128, 128, 0, 0]` | 256 MB | +| IRIX 6.5 extended | `[128, 128, 64, 64]` | 384 MB | +| IRIX 5.3 / emulator max | `[128, 128, 128, 128]` | 512 MB | + +If extended himem banks are configured but IRIX still reports 256 MB, check +monitor `mc status` for MEMCFG on banks 2–3. IRIS synthesizes himem MEMCFG when +the PROM skips them (see `rules/irix/extended-ram-memcfg.md`). + +--- + ## First-time setup — Ethernet MAC address The Indy stores its Ethernet MAC address in NVRAM (the DS1386 RTC chip). A diff --git a/README.md b/README.md index 2050ad5..fdb8c42 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,44 @@ Me and my homies Claude and Gemini present: # IRIS — Irresponsible Rust IRIX Simulator -An SGI Indy emulator, vibed into existence with Rust and AI assistance. +An SGI Indy / Indigo2 emulator, vibed into existence with Rust and AI assistance. Boots IRIX 6.5 and 5.3. Has networking. Has a framebuffer. ![IRIS running IRIX 6.5](screen.png) +## About this fork + +**This repo ([chronic8000/iris](https://github.com/chronic8000/iris)) is actively maintained and +runs well ahead of the upstream project it was forked from +([techomancer/iris](https://github.com/techomancer/iris)).** If you landed here from an older +clone or a third-party release build, check the commit date and the sections below — a lot of +hardware, GUI, and Windows workflow work lives only on this fork today. + +Upstream IRIS is an excellent Indy-focused baseline. **This fork adds:** + +| Area | What's new | +|------|------------| +| **Platforms** | **Indigo2 IP22** (fullhouse MC/IOC, dual GIO64, Newport XL on GIO) alongside Indy IP24; dual-head GUI preview; IMPACT/MGRAS + XZ/Elan scaffolds | +| **iris-gui** | Machine profiles, premiere build profile, framebuffer compositor fixes, partial frame upload, serial-console reconnect after Stop→Start, config autosave, extended RAM presets (384/512 MB) | +| **Graphics / IRQ** | Fullhouse vblank via extio `SG_RETRACE`, VC2 bootstrap for Indigo2, REX3 GFIFO/Graphics IRQ routing, tile dirty rects | +| **Performance** | HAL2 dedicated pump thread, idle-pause, REX3 SIMD paths, JIT store-isolation branch, status-bar-only idle refresh | +| **Windows 11** | One-click `.bat` launchers, premiere/JIT workflow, smoke tests, crash capture — see [wsl/README.md](wsl/README.md) | +| **CI / configs** | `irix-install/*.toml` smoke configs, Indigo2 headless smoke, `tools/tests/indigo2-prom-smoke.yaml` | +| **Docs / rules** | [docs/indigo2-ip22.md](docs/indigo2-ip22.md), [rules/perf/hardware-profiles.md](rules/perf/hardware-profiles.md), accumulated JIT/irix/snapshot notes under `rules/` | + +**Status snapshot (this fork):** + +- **Indy IP24** — primary daily-driver; IRIX desktop, X11, networking, JIT all work. +- **Indigo2 IP22** — boots to serial console; framebuffer/desktop path still in progress (use `console=d` + serial for debugging; see Indigo2 doc). +- **Upstream** — merge/rebase from `techomancer/iris` as needed; fork-specific work is on `main` here. + +Pre-built binaries and the Mac App Store GUI remain at +[danifunker/iris releases](https://github.com/danifunker/iris/releases) (upstream packaging). +For **this fork's** latest code, build from source or watch +[chronic8000/iris](https://github.com/chronic8000/iris). + + ## Q&A **Q: What is it?** @@ -45,7 +77,8 @@ boots to a usable system: shell, networking, X11, the works. - IRIX 6.5 boots to multiuser, networking works (ping, telnet, ftp) - IRIX 5.3 works too -- X11 / Newport (REX3) graphics works, with mouse and keyboard input +- **Indy IP24:** X11 / Newport (REX3) graphics works, with mouse and keyboard input +- **Indigo2 IP22:** hardware emulation + serial boot (see [docs/indigo2-ip22.md](docs/indigo2-ip22.md)); GUI framebuffer still maturing - Cranelift JIT compiler for MIPS to x86_64 translation (optional) - Copy-on-write disk overlay. Crash all day, base image stays clean - Headless mode for CI/automation @@ -100,6 +133,8 @@ before. See [HELP.md](HELP.md) for the full rundown: serial ports, monitor console, NVRAM/MAC address setup, disk image prep, and more. +**Windows 11:** full build/launch guide in [wsl/README.md](wsl/README.md). + ## PCAP bridged networking (`--features pcap`) diff --git a/TODO.md b/TODO.md index a0b872d..cd0bec8 100644 --- a/TODO.md +++ b/TODO.md @@ -3,15 +3,15 @@ DONE net - ftp hangs at 48K, examine tcp window handling rex skip first/last for blocks? -line stipple +DONE line stipple + lsadvlast (connected I_LINE continuation; rex3.rs pixel_count + setup-on-axis-mismatch) DONE why is inactive terminal caret slanted and login picture frame too, bresenham innacuracy or something else? logicop=src fastpath -fract lines +DONE fract lines (draw_fline + fline_apply_fract) -aa lines +DONE aa lines (draw_aline + endptfilter / aweight) DONE dithering @@ -37,15 +37,10 @@ look at ide fpu test, convert to user space test, compile and run on irix, fix f watchhi/watchlo register support for debugging, translate variants that fire exception when hit -vino — basic pixel pipeline, CDMC stub, and macOS UVC camera capture are - in (see [vino] in iris.toml). Remaining work: - - per-port routing via SELECT_D1: today both VINO channels see the - same source; the real chip selects between SAA7191 composite (D0) - and CDMC IndyCam (D1) per channel - - I2C repeated-start so IRIX drivers that skip the subaddr resend - for reads (the standard protocol) work without a workaround - - end-to-end visual verification under IRIX (needs vl_eoe / - vino_eoe / indycam_eoe installed) +vino — SELECT_D1 per-channel routing + I2C repeated-start in vino.rs. + Remaining: CDMC register fidelity beyond basic UYVY tweak; + end-to-end visual verification under IRIX (vl_eoe / vino_eoe / + indycam_eoe on 5.3 and 6.5) — see rules/irix/vino-verification-checklist.md DONE scsi - eject, load cd diff --git a/docs/hal2.md b/docs/hal2.md index 43d65a3..4d1784b 100644 --- a/docs/hal2.md +++ b/docs/hal2.md @@ -277,9 +277,7 @@ Example from Test_Rx_Clock: `IDR0 = 0x213` → channel 3, BRES clock 2, stereo. is unlikely to cause failures in practice, but is not strictly correct. - **`HAL2_DMA_ENDIAN_W`** — always treated as big-endian (correct default). - **`HAL2_RELAY_CONTROL_W`** — headphone relay, no analog hardware to emulate. -- **Dual-stream quad mode** (`ISR.CODEC_MODE = 1`) — both Codec A and Codec B would - need independent output streams running simultaneously. Currently only Codec A's - stream is opened; Codec B input is drained to silence. +- **Dual-stream quad mode** (`ISR.CODEC_MODE = 1`) — Codec B can open a second cpal stream when quad layout is enabled; rear channels play from Codec B DMA. - **AES TX/RX clock locking** — AESRX recovered clock as BRES master (mode `0x2` in CTRL1) is not implemented; the emulator falls back to 44100 Hz. - **Codec CTRL2 (gain/atten/mute)** — not emulated; all analog processing is bypassed. diff --git a/docs/impact-mgras-research.md b/docs/impact-mgras-research.md new file mode 100644 index 0000000..d1ff713 --- /dev/null +++ b/docs/impact-mgras-research.md @@ -0,0 +1,80 @@ +# IMPACT / MGRAS graphics — research notes + +IRIS emulates **Newport (REX3)** on Indy and Indigo2 XL paths. IMPACT is a separate +post-1995 architecture (on-board geometry + TRAM). A **preview register stub** lives in +`src/mgras.rs` (Wave 5). + +## Implementation status (IRIS) + +| Component | Status | +|-----------|--------| +| `src/mgras.rs` | Preview — per-slot register file, GIO mapping for gfx/exp0/exp1 | +| `config::ImpactSection` | `[impact]` TOML: `gfx`, `exp0`, `exp1` → `none` / `solid` / `high` / `max` | +| `physical.rs` | Maps MGRAS stub across populated GIO slots when `profile = indigo2_ip22` | +| Command processing / TRAM / GL | **Not implemented** | +| IRIX `impact` driver attach | **Not expected** to reach a working console | + +Monitor commands: `mgras` (slot summary), `impact` (hinv-style inventory preview). + +Example (preview only — Indigo2 profile): + +```toml +[machine] +profile = "indigo2_ip22" + +[impact] +gfx = "solid" +# exp0 = "high" # second board for High IMPACT +# exp1 = "max" # third board for Maximum IMPACT +``` + +## Hardware families + +| Option | GIO64 slots | Geometry | Texture RAM | IRIX driver class | +|--------|-------------|----------|-------------|-------------------| +| Newport XL | 1 | CPU (FPU) | system RAM Z-buffer | `gfx` / REX3 | +| Solid IMPACT | 1 | on-board GE | TRAM | `impact` / MGRAS | +| High IMPACT | 2 | on-board GE | TRAM | `impact` | +| Maximum IMPACT | 3 | dual GE | TRAM | `impact` | + +Valid dual-head combos with Newport: Solid+Solid, Solid+High, Solid+Max — not High+High. + +## Reference sources + +- MAME: `impact.c`, `newport.cpp`, `ip22.cpp` — register maps and IRQ fan-out +- Linux MIPS: `arch/mips/include/asm/sgi/ip22.h`, `drivers/video/impact` +- IRIX: `impact` kernel module, `libGL` IMPACT path, `hinv -c graphics` +- Hardware overview: [Wikipedia SGI Indigo2](https://en.wikipedia.org/wiki/SGI_Indigo2), [sgistuff.net Newport](http://www.sgistuff.net/hardware/graphics/newport.html) + +## Estimated emulation scope (if pursued) + +1. GIO multi-slot board model + IMPACT PSU/riser constraints +2. MGRAS geometry engine + raster engine + TRAM ASICs +3. IRIX PROM revision checks for IMPACT-ready systems +4. Dual-head policy separate from dual Newport + +**Recommendation:** Complete Indigo2 Newport boot + dual-head before pursuing full IMPACT. +Orders of magnitude more work than REX3. + +## Stub register map (`src/mgras.rs`) + +Each populated GIO slot exposes an 8 KB window at `slot_base + 0x0F0000` (same offset +pattern as Newport/XZ until a verified MGRAS map is available): + +| Offset | Name | Notes | +|--------|------|-------| +| `0x0000` | `BOARD_ID` | ASCII-tagged placeholder per slot kind | +| `0x0004` | `REVISION` | class revision placeholder | +| `0x0008` | `STATUS` | idle / FIFO-empty defaults | +| `0x000C` | `INTR_STATUS` | write-1-to-clear storage | +| `0x0010` | `INTR_ENABLE` | stored | +| `0x0018` | `FIFO_WRITE` | accepted, not executed | +| `0x0024` | `SLOT_ROLE` | slot index (0=gfx, 1=exp0, 2=exp1) | + +GIO physical bases (IP22): + +| Slot | Range | +|------|-------| +| gfx | `0x1F000000`–`0x1F3FFFFF` | +| exp0 | `0x1F400000`–`0x1F5FFFFF` | +| exp1 | `0x1F600000`–`0x1F9FFFFF` | diff --git a/docs/indigo2-ip22.md b/docs/indigo2-ip22.md new file mode 100644 index 0000000..6dbb8ba --- /dev/null +++ b/docs/indigo2-ip22.md @@ -0,0 +1,66 @@ +# Indigo2 IP22 + +Select in the GUI (**Platform → SGI Indigo2 (IP22)**) or set: + +```toml +[machine] +profile = "indigo2_ip22" +``` + +No separate build is required — the same `iris` / `iris-gui` binary supports Indy IP24 and Indigo2 IP22. + +## Hardware deltas vs Indy IP24 + +| Item | Indy (Guinness) | Indigo2 (fullhouse) | +|------|-----------------|---------------------| +| MC SYSID | `0x00000013` | `0x00000010` | +| MC GIO64_ARB ONE_GIO | set (`0x400`) | clear (dual GIO64 bus) | +| IOC sys_id | `0x26` | `0x11` | +| MAP bits 6–7 | GIO expansion IRQs | GFX DRAIN0/1 feedback | +| Primary vblank IRQ | L1 `VERTICAL_RETRACE` (extio `SG_RETRACE` on fullhouse) | same — MAP `GFX_DRAIN0` is FIFO drain only | +| Graphics | Integrated Newport @ `0x1F000000` | XL card in GIO slot (same REX3 stack) | +| Audio | HAL2 (shared A2 architecture) | HAL2 | + +## Boot checklist + +1. Build: `cargo build --release --features lightning,rex-jit` (same as Indy) +2. **Stop → Start** after changing platform (cold start picks up IRQ + VC2 bootstrap) +3. Monitor `mc status` → SYSID `00000010`, GIO64_ARB without `0x400` +3. Monitor `ioc status` → `sys_id=11`, `gc_select`/`extio` visible on fullhouse +4. Guest `hinv` → one XL graphics board (embedded Indy PROM may mis-report inventory) +5. X11 login on primary head (`/dev/gfx` / head 0) + +With **Guest** display resolution, iris bootstraps **1280×1024** on fullhouse at Start so the GUI shows a framebuffer before IRIX programs VC2 (embedded Indy PROM often delays or skips gfx init on Indigo2-class hardware). + +6. Dual-head (`graphics.heads = 2`): guest `hinv` shows two XL boards; iris-gui shows side-by-side heads; CI `screenshot` accepts `"head": 0` or `1` + +## Headless smoke (CI) + +```powershell +iris.exe --config irix-install/iris-indigo2-smoke-ci.toml +iris-ci ping +# monitor: mc status → SYSID 00000010 +``` + +## Dual-head Newport + +```toml +[graphics] +heads = 2 +``` + +Second REX3 maps at GIO slot 1 (`0x1F600000`). Snapshots include `rex3_head1.bin` and `rex3_head1_rgb.bin` / `rex3_head1_aux.bin`. + +Head 0 vblank → `VerticalRetrace` (Indy direct L1; fullhouse via extio `SG_RETRACE`). Head 1 vblank → `GioExp1` (Indy) or `GioExp0Retrace` / extio `S0_RETRACE` (fullhouse dual-head). + +## IRQ routing (fullhouse) + +Shared GIO interrupt lines (FIFO full, graphics, retrace) are latched in the IOC `extio` shadow and muxed by `gc_select` bit 0 (0 = graphics/SG slot, 1 = expansion S0). MAP bits 6–7 carry GFX drain feedback for Newport heads. + +## Not implemented + +- IMPACT / MGRAS preview stub (`src/mgras.rs`, `[impact]` config) — see `docs/impact-mgras-research.md` +- Full EXTIO bus-error and EISA interrupt paths +- Indigo2-specific PROM (embedded Indy PROM may need replacement for inventory) + +See also [`docs/interrupt_map.md`](interrupt_map.md) and [`rules/gui/machine-profile-vs-guest-ip22.md`](../rules/gui/machine-profile-vs-guest-ip22.md). diff --git a/docs/indy-xz-elan.md b/docs/indy-xz-elan.md new file mode 100644 index 0000000..d262cc9 --- /dev/null +++ b/docs/indy-xz-elan.md @@ -0,0 +1,104 @@ +# Indy XZ / Elan graphics — research notes + +IRIS emulates **Newport (REX3)** by default. The Indy **XZ** and **Elan** options use the +Express **GR3** two-board set (HQ2 + GE7/RE3 + VB2) instead. This document collects public +register-map and bus-layout research for the Wave 4 preview stub in `src/xz.rs`. + +**Status:** preview stub only — probe-friendly ID/status registers, no GL/command processing. + +## Hardware summary + +| Marketing | `hinv` / `gfxinfo` | GE count | Z-buffer | Display path | +|-----------|-------------------|----------|----------|--------------| +| Indy XZ | GR3-XZ | 4× GE7 | yes | VC1, XMAP5, VB2 RAMDAC | +| Indy Elan | GR3-Elan | 4× GE7 | yes | same family as XZ | + +XZ and Elan share the GR3 base board; Elan is the higher-clock / fully-populated variant. +Both replace Newport — IRIX loads the Express/`gfx` driver stack, not `newport`. + +Sources: + +- [Indy Technical Report ch.5 (XZ)](https://erikarn.github.io/sgi/indy/IndyReport/Indy_Report.ch5.pdf) +- [sgistuff Express overview](https://archive.irixnet.org/sgistuff/hardware/graphics/express.html) +- [Elan Technical Report](http://www.sgistuff.net/hardware/graphics/documents/ElanTR.html) + +## GIO64 placement (IP24 / Indy) + +Indy maps the graphics option in the **gfx** GIO64 slot: + +| Space | Physical range | Size | +|-------|----------------|------| +| gfx | `0x1F000000`–`0x1F3FFFFF` | 4 MB | + +MAME models Newport the same way: the REX3 register window is at offset `+0x0F0000` +within the slot (`0x1F0F0000`–`0x1F0F1FFF`, 8 KB). See +`src/devices/bus/gio64/newport.cpp` (`mem_map`). + +The XZ stub follows that layout: full 4 MB aperture owned by the device; CPU registers +decoded at `XZ_REG_BASE` (`0x1F0F0000`). + +IRQ: GIO graphics interrupt line 0 (same as Newport) → IOC `GIO_EXP0` on Indy. + +## HQ2 command engine (CPU-facing) + +HQ2 is an ~80k-gate control ASIC implementing: + +- Graphics input FIFO (CPU → command engine) +- Command sequencer / microcode store interface +- Geometry-engine delegation (4× GE7 on Indy XZ) +- Interrupt aggregation + +Public documentation does **not** publish a complete HQ2 register spec comparable to +`newport.h`. IRIX and the Express kernel driver talk to HQ2 through a small MMIO window; +Linux/NetBSD/OpenBSD Newport headers (`include/video/newport.h`) document REX3, not HQ2. + +### Stub register map (`src/xz.rs`) + +Offsets are **research placeholders** grouped like typical SGI graphics engines until a +primary source (IRIX `gfx` driver disassembly or leaked HQ2 spec) confirms them: + +| Offset | Name | R/W | Reset / read behaviour | +|--------|------|-----|------------------------| +| `0x0000` | `BOARD_ID` | R | `0x00030001` (GR3 + XZ class tag) | +| `0x0004` | `REVISION` | R | `0x00000021` (HQ2.1 placeholder) | +| `0x0008` | `STATUS` | R | FIFO empty, GE idle, RE idle | +| `0x000C` | `INTR_STATUS` | RW | write-1-to-clear (stored) | +| `0x0010` | `INTR_ENABLE` | RW | stored | +| `0x0018` | `FIFO_WRITE` | W | accepted, counted, not executed | +| `0x001C` | `FIFO_READ` | R | `0` | +| `0x0020` | `RESET` | W | soft-reset stub state | + +Unlisted offsets: read `0`, writes logged and ignored. + +## Related chips (not emulated) + +| Chip | Role | +|------|------| +| GE7 | Geometry engine (4× SIMD) | +| RE3 | Raster engine | +| VC1 | Video timing / cursor (Express VC1, not Newport VC2) | +| XMAP5| Display mode / pixel mapping | +| ZRB1 | Z-buffer ASIC | +| VB2 | RAMDAC + video I/O connectors | + +## Configuration + +```toml +[machine] +profile = "indy_ip24" + +[graphics] +board = "xz" +heads = 1 +``` + +`board = "xz"` is **Indy-only**, disables the Newport compositor, and maps `src/xz.rs` at +the gfx slot. IRIX may probe the board but will not get a working framebuffer from this stub. + +## References + +- MAME: `src/devices/bus/gio64/newport.{h,cpp}` — GIO slot layout, REX3 window offset +- MAME: `src/mame/sgi/indy_indigo2.cpp` — IP24 GIO64 slot wiring +- Linux: `include/video/newport.h` (element_kernel mirror) — Newport/REX3 only; useful DCB naming +- SGI GIO64 bus: [datasheet mirror](https://erikarn.github.io/sgi/indy/datasheets/sgi_indy_gio64.pdf) +- IRIX GIO driver chapter: [DevDriver PG ch.18](https://techpubs.jurassic.nl/library/manuals/0000/007-0911-060/sgi_html/ch18.html) diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index f25e315..9db888b 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -73,6 +73,9 @@ pcap = ["iris/pcap"] # model differs deeply). Surfaced read-only on the Memory tab via # iris::build_features::CPU. Build with: cargo build -p iris-gui --features r5k r5k = ["iris/r5k"] +# YouTube / max in-process perf: lightning (no GDB hot-path) + idle-pause on the +# embedded iris core. Build with: cargo build -p iris-gui --release --features premiere +premiere = ["iris/lightning", "iris/idle-pause"] [dependencies] # Group A (additive) features are always on for iris-gui so the user can diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 9eda1a0..313a9e1 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -1,11 +1,22 @@ use egui::{Color32, ComboBox, DragValue, Grid, RichText, ScrollArea, TextEdit, Ui}; use iris::build_features; +use serde::{Deserialize, Serialize}; use std::path::Path; use iris::config::{ - ForwardBind, ForwardProto, MachineConfig, NetMode, NfsConfig, PortForwardConfig, - ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, + ForwardBind, ForwardProto, GraphicsBoard, JitConfig, MachineConfig, MachineProfile, NetMode, + NfsConfig, PortForwardConfig, ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, }; use iris::nfsudp::NfsVersion; +use iris::vc2_timings::NewportResolution; + +use crate::ram::{ram_summary, RAM_PRESETS}; + +/// Memory-tab context: whether the VM is running and what banks were last started with. +#[derive(Clone, Copy, Debug, Default)] +pub struct MemoryUiContext { + pub running: bool, + pub started_banks: Option<[u32; 4]>, +} /// A host network interface candidate for the PCAP backend selector. This is a /// GUI-local, feature-independent copy of `iris::net_pcap::NetInterface` so the @@ -117,34 +128,71 @@ impl Tab { } } -/// 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)] +/// Legacy GUI JIT wrapper — maps to [`JitConfig`] in `MachineConfig`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct JitEnv { pub iris_jit: bool, pub max_tier: Option, pub verify: bool, pub no_stores: bool, + #[serde(default)] pub probe: String, + #[serde(default)] pub trace_file: String, + #[serde(default)] pub profile_file: String, pub no_idle: bool, + #[serde(default)] pub debug_log: String, + #[serde(default)] + pub gui_gl_capture: bool, +} + +impl From<&JitConfig> for JitEnv { + fn from(c: &JitConfig) -> Self { + Self { + iris_jit: c.enabled, + max_tier: Some(c.max_tier), + verify: c.verify, + no_stores: !c.compile_stores, + probe: c.probe.to_string(), + trace_file: c.trace_file.clone(), + profile_file: c.profile_file.clone(), + no_idle: c.no_idle, + debug_log: c.debug_log.clone(), + gui_gl_capture: c.gui_gl_capture, + } + } +} + +impl From<&JitEnv> for JitConfig { + fn from(e: &JitEnv) -> Self { + let mut c = JitConfig { + enabled: e.iris_jit, + max_tier: e.max_tier.unwrap_or(2), + verify: e.verify, + compile_stores: !e.no_stores, + no_idle: e.no_idle, + gui_gl_capture: e.gui_gl_capture, + trace_file: e.trace_file.clone(), + profile_file: e.profile_file.clone(), + debug_log: e.debug_log.clone(), + ..JitConfig::default() + }; + if let Ok(p) = e.probe.parse::() { + c.probe = p; + } + c + } } 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); } + JitConfig::from(self).apply_env(); + } + + pub fn premiere_defaults() -> Self { + JitEnv::from(&JitConfig::premiere_defaults()) } } @@ -171,7 +219,11 @@ pub enum ConfigAction { EnablePacketCapture, /// User picked a disc image for a CD-ROM while the machine is running — /// send Cmd::LoadDisc immediately without waiting for restart. - LoadDisc { id: u8, path: String }, + LoadDisc { id: u8, path: String, remount: bool }, + /// Export TOML and spawn headless iris with ci=true, then iris-ci ping. + TestCi, + /// Spawn ensure-build.bat for CLI or GUI premiere profile. + RebuildProfile { gui: bool }, } /// Everything a config tab hands back to the app for one frame. @@ -190,10 +242,10 @@ pub fn show_tab( ui: &mut Ui, tab: Tab, cfg: &mut MachineConfig, - jit: &mut JitEnv, host: &[crate::netplan::HostIface], disk_folders: &[String], pcap_ifaces: &Option, String>>, + mem_ctx: MemoryUiContext, ) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, @@ -202,17 +254,60 @@ pub fn show_tab( let net = show_network(ui, cfg, host, disk_folders, pcap_ifaces); TabOutcome { action: net.action.clone(), net, ..Default::default() } } - Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() } - Tab::Display => { show_display(ui, cfg); TabOutcome::default() } + Tab::Memory => { show_memory(ui, cfg, mem_ctx); TabOutcome::default() } + Tab::Display => { show_display(ui, cfg, mem_ctx.running); TabOutcome::default() } Tab::VideoIn => TabOutcome { action: show_vino(ui, cfg), ..Default::default() }, - Tab::Debug => { show_debug(ui, cfg, jit); TabOutcome::default() } - Tab::Ci => { show_ci(ui, cfg); TabOutcome::default() } + Tab::Debug => TabOutcome { action: show_debug(ui, cfg), ..Default::default() }, + Tab::Ci => TabOutcome { action: show_ci(ui, cfg), ..Default::default() } }).inner } fn show_general(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { let mut action = ConfigAction::None; ui.heading("General"); + ui.label(format!("Platform: {}", cfg.machine.profile.label())); + ComboBox::from_id_salt("machine_profile") + .selected_text(cfg.machine.profile.label()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cfg.machine.profile, MachineProfile::IndyIp24, MachineProfile::IndyIp24.label()); + ui.selectable_value(&mut cfg.machine.profile, MachineProfile::Indigo2Ip22, MachineProfile::Indigo2Ip22.label()); + }); + ui.label( + RichText::new( + "IRIX Software Manager and hinv report IP22 as the platform family on Indy — that is normal. \ + IP24 is this board's product name.", + ) + .weak() + .small(), + ); + ui.label( + RichText::new( + "The main viewport is the Newport framebuffer (X11 / gfx), not the serial console. \ + PROM and login appear on the monitor (127.0.0.1:8888) or IRIX serial until X starts.", + ) + .weak() + .small(), + ); + if cfg.machine.profile == MachineProfile::Indigo2Ip22 { + ui.label( + RichText::new( + "Fullhouse layout: MC SYSID 0x10, dual GIO64, Newport XL @ gfx slot. \ + Same IRIX disk images as Indy. With Guest resolution, iris bootstraps \ + 1280×1024 until IRIX programs the display.", + ) + .weak() + .small(), + ); + } + ui.horizontal(|ui| { + ui.label("Newport heads"); + ui.add(egui::DragValue::new(&mut cfg.graphics.heads).range(1..=2).speed(0.1)); + if cfg.graphics.heads == 2 { + ui.label(RichText::new("(dual-head: second REX3 @ GIO slot 1)").weak().small()); + } + }); + show_resolution_picker(ui, cfg, false); + ui.add_space(4.0); 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); @@ -260,12 +355,64 @@ fn show_general(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { 'ultra64' fork running alongside IRIS; load ROMs with `gload` from \ IRIX. Applies on next Start. See docs/ultra64.md.", ); + ui.label( + RichText::new( + "Settings autosave ~600 ms after edits (watch for * next to the machine name). \ + Platform, resolution, and Ultra64 apply on the next Stop → Start.", + ) + .weak() + .small(), + ); } action } -fn show_memory(ui: &mut Ui, cfg: &mut MachineConfig) { +fn show_resolution_picker(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) { + let newport = cfg.graphics.board == GraphicsBoard::Newport && !cfg.headless; + ui.horizontal(|ui| { + ui.label("Display resolution"); + if !newport { + ui.label( + RichText::new("(Newport only — enable graphics, board = newport)") + .weak() + .small(), + ); + return; + } + ComboBox::from_id_salt("graphics_resolution") + .selected_text(cfg.graphics.resolution.label()) + .show_ui(ui, |ui| { + for mode in [ + NewportResolution::Guest, + NewportResolution::Res1024x768, + NewportResolution::Res1280x960, + NewportResolution::Res1280x1024, + ] { + ui.selectable_value(&mut cfg.graphics.resolution, mode, mode.label()); + } + }); + }); + if newport { + ui.label( + RichText::new( + "Presets program VC2 at VM Start (standard Indy 4:3 / 5:4 modes). \ + Guest leaves timing to IRIX/setmon. Widescreen (1080p) is not supported on Newport hardware.", + ) + .weak() + .small(), + ); + if running { + ui.label( + RichText::new("Stop the VM — resolution applies on next Start.") + .color(Color32::from_rgb(220, 170, 90)) + .small(), + ); + } + } +} + +fn show_memory(ui: &mut Ui, cfg: &mut MachineConfig, mem_ctx: MemoryUiContext) { ui.heading("Processor"); Grid::new("cpu_grid").num_columns(2).striped(true).show(ui, |ui| { ui.label("CPU"); @@ -280,26 +427,69 @@ fn show_memory(ui: &mut Ui, cfg: &mut MachineConfig) { ui.separator(); ui.heading("Memory"); + if mem_ctx.running { + ui.label( + RichText::new("Stop the VM to change RAM — edits apply at the next Start.") + .color(Color32::from_rgb(220, 170, 90)), + ); + if let Some(started) = mem_ctx.started_banks { + if started != cfg.banks { + ui.label(format!( + "Running guest: {} · Config (pending): {}", + ram_summary(&started), + ram_summary(&cfg.banks), + )); + } else { + ui.label(format!("Running guest: {}", ram_summary(&started))); + } + } + } else { + ui.label(RichText::new("Applied at next Start").weak()); + ui.label( + RichText::new( + "Authentic Indy max is 256 MB. IRIX 6.5 supports 384 MB; IRIX 5.3 up to 512 MB \ + (emulator extended layout).", + ) + .weak() + .small(), + ); + } + ui.add_space(4.0); + ui.label("Quick presets:"); + ui.horizontal_wrapped(|ui| { + for &p in RAM_PRESETS { + if ui + .add_enabled(!mem_ctx.running, egui::Button::new(format!("{p} MB"))) + .on_disabled_hover_text("Stop the VM to change RAM") + .clicked() + { + cfg.banks = crate::dialogs::new_machine::distribute_ram(p); + } + } + }); 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.add_enabled_ui(!mem_ctx.running, |ui| { + 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")); + ui.label(format!("Total: {}", ram_summary(&cfg.banks))); } -fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) { +fn show_display(ui: &mut Ui, cfg: &mut MachineConfig, running: bool) { ui.heading("Display"); + show_resolution_picker(ui, cfg, running); + ui.add_space(6.0); 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)) @@ -318,6 +508,27 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) { ui.checkbox(&mut cfg.no_audio, ""); ui.end_row(); }); + if !cfg.no_audio { + ui.separator(); + ui.heading("Audio (HAL2)"); + ui.label( + RichText::new("Underrun count: monitor telnet 127.0.0.1:8888 → hal2 status") + .weak() + .small(), + ); + Grid::new("audio_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Pre-buffer (ms)"); + ui.add(DragValue::new(&mut cfg.audio.prebuf_ms).range(5..=200)); + ui.end_row(); + ui.label("cpal buffer (frames)"); + let mut frames = cfg.audio.cpal_buffer_frames.unwrap_or(0); + if ui.add(DragValue::new(&mut frames).range(0..=8192)).changed() { + cfg.audio.cpal_buffer_frames = if frames == 0 { None } else { Some(frames) }; + } + ui.end_row(); + }); + ui.label(RichText::new("44100 Hz preferred when host supports it (see hal2 status).").weak().small()); + } } fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) { @@ -367,7 +578,9 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) // count-driven queue decides whether this replaces the current // disc or joins the changer cycle. if dev.cdrom && e.picked && !dev.path.is_empty() { - action = ConfigAction::LoadDisc { id, path: dev.path.clone() }; + action = ConfigAction::LoadDisc { + id, path: dev.path.clone(), remount: true, + }; } ui.end_row(); if dev.path.ends_with(".chd") && !build_features::CHD { @@ -1128,7 +1341,43 @@ fn show_vino(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { action } -fn show_debug(ui: &mut Ui, cfg: &mut MachineConfig, jit: &mut JitEnv) { +fn show_debug(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { + let mut action = ConfigAction::None; + ui.heading("Build features"); + Grid::new("build_features_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("CPU"); + ui.label(build_features::CPU); + ui.end_row(); + ui.label("MIPS JIT"); + ui.label(if build_features::JIT { "enabled at compile time" } else { "not built" }); + ui.end_row(); + ui.label("REX3 JIT"); + ui.label(if build_features::REX_JIT { "enabled at compile time" } else { "not built" }); + ui.end_row(); + ui.label("Lightning"); + ui.label(if build_features::LIGHTNING { "yes (no GDB)" } else { "no" }); + ui.end_row(); + ui.label("Idle pause"); + ui.label(if build_features::IDLE_PAUSE { "yes" } else { "no" }); + ui.end_row(); + ui.label("CHD / PCAP / Camera"); + ui.label(format!( + "CHD={} PCAP={} Camera={}", + build_features::CHD, + build_features::PCAP, + build_features::CAMERA, + )); + ui.end_row(); + }); + ui.label( + RichText::new( + "Status-bar MIPS = host emulation throughput. IRIX System Manager MHz \ + (from hinv) is guest inventory — changing it does not make IRIX faster.", + ) + .weak() + .small(), + ); + ui.separator(); ui.heading("Debug / JIT"); if build_features::LIGHTNING { ui.label(RichText::new( @@ -1147,63 +1396,159 @@ fn show_debug(ui: &mut Ui, cfg: &mut MachineConfig, jit: &mut JitEnv) { }); } ui.separator(); + ui.label("Capture (iris-gui framebuffer path)"); + Grid::new("capture_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("OpenGL capture (IRIS_GUI_GL)"); + ui.checkbox(&mut cfg.jit.gui_gl_capture, "") + .on_hover_text("Use GlCompositor on the refresh thread (faster GL demos in GUI). CLI iris.exe is still fastest for recording."); + ui.end_row(); + }); + ui.label( + RichText::new("After audio-heavy demos: telnet 127.0.0.1:8888 → hal2 status (cpal underruns).") + .weak() + .small(), + ); + 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.checkbox(&mut cfg.jit.enabled, ""); ui.end_row(); ui.label("Max tier (0=ALU, 1=Loads, 2=Full)"); - let mut t = jit.max_tier.unwrap_or(2); + let mut t = cfg.jit.max_tier; if ui.add(DragValue::new(&mut t).range(0..=2)).changed() { - jit.max_tier = Some(t); + cfg.jit.max_tier = t; } ui.end_row(); ui.label("Verify against interpreter"); - ui.checkbox(&mut jit.verify, ""); + ui.checkbox(&mut cfg.jit.verify, ""); ui.end_row(); - ui.label("Disable JIT stores (diagnostic)"); - ui.checkbox(&mut jit.no_stores, ""); + ui.label("Compile JIT stores (experimental)"); + ui.checkbox(&mut cfg.jit.compile_stores, "") + .on_hover_text("When enabled, MIPS JIT may compile stores (write-log rollback path)"); ui.end_row(); ui.label("Probe interval"); - ui.add(TextEdit::singleline(&mut jit.probe).hint_text("default 200").desired_width(120.0)); + ui.add(DragValue::new(&mut cfg.jit.probe).range(50..=5000).speed(10)); + ui.end_row(); + + ui.label("Probe min"); + ui.add(DragValue::new(&mut cfg.jit.probe_min).range(10..=1000).speed(5)); ui.end_row(); ui.label("Trace file"); - path_row(ui, "jit_trace", &mut jit.trace_file, Pick::SaveFile, ANY_FILTERS); + path_row(ui, "jit_trace", &mut cfg.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); + path_row(ui, "jit_profile", &mut cfg.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.checkbox(&mut cfg.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.add(TextEdit::singleline(&mut cfg.jit.debug_log).hint_text("all, or e.g. mc,mips").desired_width(280.0)); + ui.end_row(); + }); + ui.separator(); + ui.label(RichText::new("Build profiles (from repo root):").strong()); + ui.horizontal(|ui| { + if ui.button("Rebuild Premiere CLI…").on_hover_text("lightning,rex-jit,jit,idle-pause").clicked() { + action = ConfigAction::RebuildProfile { gui: false }; + } + if ui.button("Rebuild Premiere GUI…").on_hover_text("--features premiere").clicked() { + action = ConfigAction::RebuildProfile { gui: true }; + } + }); + ui.monospace("cargo build --release --bin iris --features lightning,rex-jit,jit,idle-pause"); + ui.monospace("cargo build -p iris-gui --release --features premiere"); + ui.monospace("cargo build -p iris-gui --release --features iris/r5k,iris/r5ksc # R5000SC"); + ui.separator(); + ui.heading("Host performance"); + Grid::new("perf_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Thread affinity"); + ui.checkbox(&mut cfg.perf.thread_affinity, ""); + ui.end_row(); + ui.label("MIPS CPU core"); + let mut cpu = cfg.perf.cpu_core.unwrap_or(0) as i32; + if ui.add(DragValue::new(&mut cpu).range(0..=63)).changed() { + cfg.perf.cpu_core = Some(cpu as u32); + } + ui.end_row(); + ui.label("REX3 processor core"); + let mut rex = cfg.perf.rex3_core.unwrap_or(1) as i32; + if ui.add(DragValue::new(&mut rex).range(0..=63)).changed() { + cfg.perf.rex3_core = Some(rex as u32); + } + ui.end_row(); + ui.label("REX3 refresh core"); + let mut refc = cfg.perf.refresh_core.unwrap_or(2) as i32; + if ui.add(DragValue::new(&mut refc).range(0..=63)).changed() { + cfg.perf.refresh_core = Some(refc as u32); + } ui.end_row(); }); + action } -fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) { +fn show_ci(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { ui.heading("CI / Automation"); + ui.label("Export TOML then run iris with ci=true — same hardware as GUI. Windows default socket: 127.0.0.1:19851"); 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.label("CI socket (Unix path or host:port)"); + ui.add(TextEdit::singleline(&mut cfg.ci_socket).desired_width(280.0)); ui.end_row(); ui.label("Keep window visible (--ci-display)"); ui.checkbox(&mut cfg.ci_display, ""); ui.end_row(); + ui.label("Serial transcript log"); + let mut log = cfg.serial_log.clone().unwrap_or_default(); + if ui.add(TextEdit::singleline(&mut log).desired_width(280.0)).changed() { + cfg.serial_log = if log.is_empty() { None } else { Some(log) }; + } + ui.end_row(); + ui.label("Defer SCSI interrupts"); + ui.checkbox(&mut cfg.scsi_deferred_int, "") + .on_hover_text("Required for OpenBSD/NetBSD; disable if SCSI timeouts"); + ui.end_row(); + ui.label("Lock aspect ratio"); + ui.checkbox(&mut cfg.lock_aspect_ratio, ""); + ui.end_row(); + ui.label("Scroll pixels per line"); + ui.add(DragValue::new(&mut cfg.mouse_scroll_pixels_per_line).speed(1.0).range(5.0..=200.0)); + ui.end_row(); }); + ui.add_space(8.0); + let mut action = ConfigAction::None; + if ui.button("Test CI connection (iris-ci ping)").clicked() { + let socket = cfg.ci_socket.clone(); + std::thread::spawn(move || { + let status = std::process::Command::new("target/release/iris-ci.exe") + .arg("ping") + .env("IRIS_CI_SOCKET", &socket) + .output(); + match status { + Ok(o) if o.status.success() => eprintln!("iris-ci ping: OK"), + Ok(o) => eprintln!("iris-ci ping failed: {}", String::from_utf8_lossy(&o.stderr)), + Err(e) => eprintln!("iris-ci ping: {e} (build iris-ci and start iris with ci=true)"), + } + }); + ui.ctx().request_repaint(); + } + if ui.button("Test in CI (export + headless boot + ping)").clicked() { + action = ConfigAction::TestCi; + } + ui.label(RichText::new("Export TOML, start iris with ci=true, then ping.").weak().small()); + action } /// Serialize `cfg` back to TOML string in the same style as iris.toml. diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index d50fd28..4854ca9 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -1,6 +1,8 @@ use eframe::egui::{self, Color32, ComboBox, Grid, RichText, TextEdit}; use iris::config::{MachineConfig, ScsiDeviceConfig, VALID_BANK_SIZES}; +use crate::ram::RAM_PRESETS; + /// "New machine" startup dialog. /// Pops up at first run (or on `File → New machine…`) to bootstrap a config. pub struct NewMachineDialog { @@ -45,8 +47,6 @@ impl Default for NewMachineDialog { } } -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; diff --git a/iris-gui/src/framebuffer.rs b/iris-gui/src/framebuffer.rs index 522af68..61db910 100644 --- a/iris-gui/src/framebuffer.rs +++ b/iris-gui/src/framebuffer.rs @@ -1,13 +1,15 @@ //! Headless renderer that captures the composited REX3 framebuffer into a //! shared buffer for egui to upload as a texture each frame. //! -//! This renderer has no GL context. It runs `SwCompositor::compose_pixels()` -//! directly (CPU-only, no GL upload) and reads from the resulting pixel buffer. +//! Default path: `CaptureRenderer` (CPU `SwCompositor`). +//! Set `IRIS_GUI_GL=1` for GPU `GlCompositor` + readback (faster for GL demos). -use iris::rex3::Renderer; -use iris::disp::{Rex3Screen, StatusBar, StatusBarTexture, BarStats}; +use iris::compositor::{Compositor, SwCompositor}; use iris::debug_overlay::DebugOverlay; -use iris::compositor::SwCompositor; +use iris::disp::{BarStats, Rex3Screen, StatusBar, StatusBarTexture, STATUS_BAR_HEIGHT}; +use iris::gl_compositor::GlCompositor; +use iris::headless_gl::HeadlessGl; +use iris::rex3::Renderer; use parking_lot::{Mutex, MutexGuard}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -22,12 +24,13 @@ pub struct Frame { /// Bumped every time a new frame is captured; egui uses this to skip the /// texture upload when nothing has changed. pub seq: u64, + /// First scanline included in `rgba` update (0 = full frame). + pub dirty_y: u32, + /// Number of scanlines updated (`0` or `height` = full frame). + pub dirty_h: u32, } /// Shared latest-frame slot. The renderer writes; the GUI reads. -/// -/// `seq` is mirrored into a lock-free atomic so the GUI can check for new -/// frames without taking the mutex or cloning the multi-MB buffer. #[derive(Default, Clone)] pub struct FrameSink { frame: Arc>, @@ -37,16 +40,10 @@ pub struct FrameSink { 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. Gate this on `seq()` having changed to avoid - /// copying the whole buffer on every repaint when nothing is new. pub fn snapshot(&self) -> Frame { self.frame.lock().clone() } - /// Reset to the "no frame yet" state (seq 0, blank frame). Call before a - /// fresh run starts rendering so a restart shows the "waiting for first - /// frame" placeholder instead of the previous run's last frame. pub fn reset(&self) { *self.frame.lock() = Frame::default(); self.seq.store(0, Ordering::Release); @@ -56,64 +53,233 @@ impl FrameSink { } /// Headless `Renderer` that captures the composited frame into a `FrameSink`. -/// -/// Runs `SwCompositor::compose_pixels()` (CPU-only, no GL context needed) and -/// packs the result into egui-friendly RGBA bytes. pub struct CaptureRenderer { - sink: FrameSink, - seq: u64, - compositor: SwCompositor, + sink: FrameSink, + seq: u64, + compositor: SwCompositor, + last_pixels: Vec, } impl CaptureRenderer { pub fn new(sink: FrameSink) -> Self { - Self { sink, seq: 0, compositor: SwCompositor::new() } + Self { + sink, + seq: 0, + compositor: SwCompositor::new(), + last_pixels: Vec::new(), + } } } +/// IRIX status strip is the bottom `STATUS_BAR_HEIGHT` rows of the visible frame. +fn status_bar_y(height: usize) -> usize { + height.saturating_sub(STATUS_BAR_HEIGHT) +} + impl Renderer for CaptureRenderer { fn present( &mut self, screen: &mut Rex3Screen, _overlay: &mut DebugOverlay, - _status: &mut StatusBar, + status: &mut StatusBar, _sbtex: &mut StatusBarTexture, - _stats: &BarStats, + stats: &BarStats, _need_readback: bool, + live_fb_rgb: Option<&[u32]>, + live_fb_aux: Option<&[u32]>, ) { + let _ = (live_fb_rgb, live_fb_aux); let width = screen.width; let height = screen.height; if width == 0 || height == 0 { return; } - // Run SW compositor pixel loop (no GL). - let src = screen.compositor_source(); + let needed = 2048 * 1024; + if self.last_pixels.len() < needed { + self.last_pixels.resize(needed, 0); + } + + if screen.status_bar_only && self.sink.seq() > 0 { + status.update(stats.hb); + let bar_y = status_bar_y(height); + status.render(&mut self.last_pixels, width, bar_y, stats); + pack_rows( + &self.sink, + &self.last_pixels, + width, + height, + bar_y, + STATUS_BAR_HEIGHT, + &mut self.seq, + ); + return; + } + + let src = screen.compositor_source_from(live_fb_rgb, live_fb_aux); self.compositor.compose_pixels(&src); drop(src); + self.last_pixels.copy_from_slice(self.compositor.pixels()); + + status.update(stats.hb); + status.render(&mut self.last_pixels, width, status_bar_y(height), stats); + + pack_sw_frame(&self.sink, &self.last_pixels, width, height, &mut self.seq); + } + + fn resize(&mut self, _width: usize, _height: usize) {} +} + +/// GPU compositor path: `GlCompositor` on a hidden GL context (REX3-Refresh thread). +pub struct GlCaptureRenderer { + sink: FrameSink, + seq: u64, + headless: HeadlessGl, + compositor: GlCompositor, + rgba: Vec, + last_pixels: Vec, +} + +impl GlCaptureRenderer { + pub fn new(sink: FrameSink) -> Option { + let headless = HeadlessGl::new()?; + eprintln!("iris-gui: OpenGL capture renderer enabled (IRIS_GUI_GL=1)"); + Some(Self { + sink, + seq: 0, + headless, + compositor: GlCompositor::new(), + rgba: Vec::new(), + last_pixels: Vec::new(), + }) + } +} + +impl Renderer for GlCaptureRenderer { + fn present( + &mut self, + screen: &mut Rex3Screen, + _overlay: &mut DebugOverlay, + status: &mut StatusBar, + _sbtex: &mut StatusBarTexture, + stats: &BarStats, + _need_readback: bool, + live_fb_rgb: Option<&[u32]>, + live_fb_aux: Option<&[u32]>, + ) { + let _ = (live_fb_rgb, live_fb_aux); + let width = screen.width; + let height = screen.height; + if width == 0 || height == 0 { return; } - let needed = width * height * 4; - let mut frame = self.sink.lock(); - if frame.rgba.len() != needed { - frame.rgba = vec![0u8; needed]; + let needed = 2048 * 1024; + if self.last_pixels.len() < needed { + self.last_pixels.resize(needed, 0); } - frame.width = width; - frame.height = height; - - // compositor buf is stride-2048, 0xFFBBGGRR. - // egui wants tightly-packed [R, G, B, A] — same byte order on LE. - let buf = self.compositor.pixels(); - for y in 0..height { - let src_row = &buf[y * 2048..y * 2048 + width]; - let dst_row = &mut frame.rgba[y * width * 4..(y + 1) * width * 4]; - 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()); - } + + if screen.status_bar_only && self.sink.seq() > 0 { + status.update(stats.hb); + let bar_y = status_bar_y(height); + status.render(&mut self.last_pixels, width, bar_y, stats); + pack_rows( + &self.sink, + &self.last_pixels, + width, + height, + bar_y, + STATUS_BAR_HEIGHT, + &mut self.seq, + ); + return; + } + + if self.rgba.len() < needed { + self.rgba.resize(needed, 0); } + let src = screen.compositor_source_from(live_fb_rgb, live_fb_aux); + let gl = &self.headless.gl; + let _tex = self.compositor.compose(&src, gl); + drop(src); + self.compositor.readback_to_screen(&mut self.rgba, width, height, gl); + self.last_pixels.copy_from_slice(&self.rgba[..needed]); - self.seq = self.seq.wrapping_add(1); - frame.seq = self.seq; - drop(frame); - self.sink.seq.store(self.seq, Ordering::Release); + status.update(stats.hb); + status.render(&mut self.last_pixels, width, status_bar_y(height), stats); + + pack_sw_frame(&self.sink, &self.last_pixels, width, height, &mut self.seq); } fn resize(&mut self, _width: usize, _height: usize) {} } + +impl Drop for GlCaptureRenderer { + fn drop(&mut self) { + self.compositor.destroy(&self.headless.gl); + } +} + +fn pack_sw_frame(sink: &FrameSink, pixels: &[u32], width: usize, height: usize, seq: &mut u64) { + let needed = width * height * 4; + let mut frame = sink.lock(); + if frame.rgba.len() != needed { + frame.rgba = vec![0u8; needed]; + } + frame.width = width; + frame.height = height; + frame.dirty_y = 0; + frame.dirty_h = height as u32; + + for y in 0..height { + let src_row = &pixels[y * 2048..y * 2048 + width]; + let dst_row = &mut frame.rgba[y * width * 4..(y + 1) * width * 4]; + 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()); + } + } + + *seq = seq.wrapping_add(1); + frame.seq = *seq; + drop(frame); + sink.seq.store(*seq, Ordering::Release); +} + +fn pack_rows( + sink: &FrameSink, + pixels: &[u32], + width: usize, + height: usize, + start_y: usize, + row_count: usize, + seq: &mut u64, +) { + let mut frame = sink.lock(); + if frame.width != width || frame.height != height || frame.rgba.is_empty() { + pack_sw_frame(sink, pixels, width, height, seq); + return; + } + + frame.dirty_y = start_y as u32; + frame.dirty_h = row_count as u32; + + for y in start_y..start_y + row_count.min(height.saturating_sub(start_y)) { + let src_row = &pixels[y * 2048..y * 2048 + width]; + let dst_row = &mut frame.rgba[y * width * 4..(y + 1) * width * 4]; + 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()); + } + } + + *seq = seq.wrapping_add(1); + frame.seq = *seq; + drop(frame); + sink.seq.store(*seq, Ordering::Release); +} + +/// Pick CPU or GL capture renderer based on `IRIS_GUI_GL=1`. +pub fn new_capture_renderer(sink: FrameSink) -> Box { + if std::env::var("IRIS_GUI_GL").map(|v| v == "1").unwrap_or(false) { + if let Some(r) = GlCaptureRenderer::new(sink.clone()) { + return Box::new(r); + } + eprintln!("iris-gui: IRIS_GUI_GL=1 but headless GL init failed — using CPU compositor"); + } + Box::new(CaptureRenderer::new(sink)) +} diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index d322ce0..22e93d8 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -1,4 +1,4 @@ -use crate::framebuffer::{CaptureRenderer, FrameSink}; +use crate::framebuffer::{new_capture_renderer, FrameSink}; use crossbeam_channel::{unbounded, Receiver, Sender}; use iris::config::{MachineConfig, PortForwardConfig}; use iris::machine::Machine; @@ -42,7 +42,11 @@ pub enum Cmd { CowReset { base: String, chd: bool }, /// Load a disc image into a CD-ROM device (live hot-swap). /// Valid only while running. The path is loaded as the active disc. - LoadDisc { id: u8, path: String }, + LoadDisc { id: u8, path: String, remount: bool }, + /// Empty the CD-ROM tray on a running machine (SCSI layer only). + EjectCdrom { id: u8 }, + /// Inject an IRIX shell command to (re)mount /CDROM for SCSI `id`. + RemountCdrom { id: u8 }, Quit, } @@ -140,6 +144,8 @@ pub struct EmulatorHandle { /// Shared latest-framebuffer slot, written by the CaptureRenderer /// inside the worker and read by the GUI each egui frame. pub frame_sink: FrameSink, + /// Second Newport head (`graphics.heads == 2`); inactive when seq stays 0. + pub frame_sink_head1: 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. @@ -156,7 +162,9 @@ impl EmulatorHandle { let (cmd_tx, cmd_rx) = unbounded::(); let (evt_tx, evt_rx) = unbounded::(); let frame_sink = FrameSink::new(); + let frame_sink_head1 = FrameSink::new(); let sink_for_worker = frame_sink.clone(); + let sink_head1_for_worker = frame_sink_head1.clone(); let ps2: Arc>>> = Arc::new(Mutex::new(None)); let ps2_for_worker = ps2.clone(); let thread = std::thread::Builder::new() @@ -169,13 +177,14 @@ impl EmulatorHandle { // 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)) + .spawn(move || worker_loop(cmd_rx, evt_tx, sink_for_worker, sink_head1_for_worker, ps2_for_worker)) .expect("spawn iris-gui-emu thread"); Self { cmd_tx, evt_rx, thread: Some(thread), frame_sink, + frame_sink_head1, ps2, status: Status::default(), net_seen_frames: 0, @@ -297,6 +306,7 @@ fn worker_loop( cmd_rx: Receiver, evt_tx: Sender, frame_sink: FrameSink, + frame_sink_head1: FrameSink, ps2_slot: Arc>>>, ) { let mut machine: Option> = None; @@ -353,6 +363,7 @@ fn worker_loop( // shows the "waiting for first REX3 frame" placeholder instead // of the stale screen until its first frame is rendered. frame_sink.reset(); + frame_sink_head1.reset(); // 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 @@ -365,6 +376,8 @@ fn worker_loop( // conflict with eframe. let cfg_owned = *cfg; let sink_for_machine = frame_sink.clone(); + let sink_for_head1 = frame_sink_head1.clone(); + let heads = cfg_owned.graphics.heads; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut m = Box::new(Machine::new(cfg_owned)); m.register_system_controller(); @@ -373,7 +386,13 @@ fn worker_loop( // sink the GUI can read. if let Some(rex3) = m.get_rex3() { *rex3.renderer.lock() = - Some(Box::new(CaptureRenderer::new(sink_for_machine))); + Some(new_capture_renderer(sink_for_machine)); + } + if heads >= 2 { + if let Some(rex3) = m.get_rex3_head1() { + *rex3.renderer.lock() = + Some(new_capture_renderer(sink_for_head1)); + } } m.start(); m @@ -571,7 +590,7 @@ fn worker_loop( Err(e) => { let _ = evt_tx.send(Evt::Error(format!("screenshot failed: {e}"))); } } } - Ok(Cmd::LoadDisc { id, path }) => { + Ok(Cmd::LoadDisc { id, path, remount }) => { match machine.as_ref() { Some(m) => { match m.hpc3().scsi().load_disc(id as usize, path.clone()) { @@ -580,7 +599,15 @@ fn worker_loop( .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| loaded_path.clone()); - let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: loaded {filename}"))); + let msg = if remount { + m.remount_cdrom_guest(id); + format!( + "SCSI #{id}: loaded {filename} — remount sent to console" + ) + } else { + format!("SCSI #{id}: loaded {filename}") + }; + let _ = evt_tx.send(Evt::Error(msg)); } Err(e) => { let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: {e}"))); @@ -590,6 +617,32 @@ fn worker_loop( None => { let _ = evt_tx.send(Evt::Error("load disc: not running".into())); } } } + Ok(Cmd::EjectCdrom { id }) => { + match machine.as_ref() { + Some(m) => { + match m.hpc3().scsi().eject_to_empty(id as usize) { + Ok(()) => { + let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: tray empty"))); + } + Err(e) => { + let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: {e}"))); + } + } + } + None => { let _ = evt_tx.send(Evt::Error("eject: not running".into())); } + } + } + Ok(Cmd::RemountCdrom { id }) => { + match machine.as_ref() { + Some(m) => { + m.remount_cdrom_guest(id); + let _ = evt_tx.send(Evt::Error(format!( + "SCSI #{id}: /CDROM remount sent to console" + ))); + } + None => { let _ = evt_tx.send(Evt::Error("remount: not running".into())); } + } + } Ok(Cmd::Quit) | Err(_) => { *ps2_slot.lock() = None; if let Some(m) = machine.take() { diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 4d73be1..882b62e 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -10,15 +10,17 @@ mod input; mod macos_sandbox; mod netfix; mod netplan; +mod ram; mod safe_stop; mod scsi_menu; mod serial_console; mod settings; mod single_instance; -use config_ui::{cfg_to_toml, show_tab, ConfigAction, JitEnv, Tab}; +use config_ui::{cfg_to_toml, show_tab, ConfigAction, MemoryUiContext, Tab}; use dialogs::create_disk::CreateDiskDialog; use dialogs::new_machine::{distribute_ram, NewMachineDialog}; +use ram::{ram_summary, RAM_PRESETS}; use eframe::egui; use egui::{Color32, RichText, ViewportCommand}; use handle::{Cmd, EmulatorHandle, Evt, NetState}; @@ -153,7 +155,8 @@ struct App { cfg_dirty: bool, /// Timestamp of the most recent edit; used to debounce auto-save. cfg_dirty_since: Option, - jit: JitEnv, + /// Banks passed to the last `Cmd::Start` (guest-visible RAM is fixed until Stop). + started_banks: Option<[u32; 4]>, tab: Tab, emu: EmulatorHandle, toast: Option<(String, std::time::Instant)>, @@ -197,9 +200,12 @@ struct App { /// egui texture holding the most recent REX3 framebuffer. Allocated /// lazily on the first frame that needs it. fb_tex: Option, + /// Second Newport head when `graphics.heads == 2`. + fb_tex_head1: 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, + last_fb_seq_head1: u64, /// Filter the framebuffer texture is currently uploaded with: `true` = /// NEAREST (crisp; used at integer device-pixel scales), `false` = LINEAR /// (smooths the uneven pixel doubling at fractional scales). Tracked so we @@ -451,7 +457,7 @@ impl App { cfg_path, cfg_dirty: false, cfg_dirty_since: None, - jit: JitEnv::default(), + started_banks: None, tab: Tab::General, emu: EmulatorHandle::spawn(), toast: None, @@ -472,7 +478,9 @@ impl App { save_state_name: "snap1".into(), restore_state_name: "snap1".into(), fb_tex: None, + fb_tex_head1: None, last_fb_seq: 0, + last_fb_seq_head1: 0, fb_nearest: true, fb_scale: 0.0, pending_fb_snap: false, @@ -578,6 +586,100 @@ impl App { } } + /// Export config + enable premiere JIT defaults for CLI recording workflow. + fn prepare_for_premiere(&mut self) { + if self.emu.is_running() { + self.toast("Stop the VM first — premiere CLI uses its own iris.exe process"); + return; + } + self.cfg.scale = 1; + self.cfg.jit = iris::config::JitConfig::premiere_defaults(); + self.mark_dirty(); + if self.cfg_dirty { self.flush_machine(); } + let path = PathBuf::from("irix-install/iris-windows.toml"); + match cfg_to_toml(&self.cfg) { + Ok(s) => { + if let Err(e) = std::fs::write(&path, s) { + self.toast(format!("export failed: {e}")); + return; + } + self.cfg_path = Some(path); + self.cfg_dirty = false; + } + Err(e) => { + self.toast(format!("serialize failed: {e}")); + return; + } + } + let _ = self.prefs.save(); + self.toast( + "Premiere ready: iris-windows.toml exported. Warm JIT (wsl\\warm-jit-profiles.ps1), \ + then run wsl\\run-iris-premiere.bat for 3D recording.", + ); + } + + fn spawn_rebuild_profile(&mut self, gui: bool) { + let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let bat = if gui { + root.join("wsl").join("ensure-build.bat") + } else { + root.join("wsl").join("ensure-build.bat") + }; + let arg = if gui { "gui" } else { "cli" }; + if gui { + std::env::set_var("IRIS_GUI_FEATURES", "premiere"); + } + match std::process::Command::new("cmd") + .args(["/c", bat.to_string_lossy().as_ref(), arg]) + .current_dir(&root) + .spawn() + { + Ok(_) => self.toast(format!("Rebuilding {} in background — watch terminal", arg)), + Err(e) => self.toast(format!("Rebuild failed to start: {e}")), + } + } + + fn test_ci_launch(&mut self) { + if self.emu.is_running() { + self.toast("Stop the embedded VM first — CI test spawns a separate iris.exe"); + return; + } + let mut cfg = self.cfg.clone(); + cfg.ci = true; + cfg.headless = true; + let path = PathBuf::from("irix-install/iris-ci-test.toml"); + match cfg_to_toml(&cfg) { + Ok(s) => { + if let Err(e) = std::fs::write(&path, s) { + self.toast(format!("export failed: {e}")); + return; + } + } + Err(e) => { + self.toast(format!("serialize failed: {e}")); + return; + } + } + let socket = cfg.ci_socket.clone(); + let config = path.to_string_lossy().to_string(); + self.toast("CI test: starting headless iris…"); + std::thread::spawn(move || { + let _child = std::process::Command::new("target/release/iris.exe") + .args(["--config", &config]) + .spawn(); + std::thread::sleep(std::time::Duration::from_secs(8)); + let out = std::process::Command::new("target/release/iris-ci.exe") + .arg("ping") + .env("IRIS_CI_SOCKET", &socket) + .output(); + match out { + Ok(o) if o.status.success() => eprintln!("CI test: ping OK"), + Ok(o) => eprintln!("CI test ping failed: {}", String::from_utf8_lossy(&o.stderr)), + Err(e) => eprintln!("CI test: {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. This also harvests a @@ -626,7 +728,7 @@ impl App { if !std::path::Path::new(&self.cfg.prom).exists() { self.toast(format!("'{}' not found — using embedded PROM", self.cfg.prom)); } - self.jit.export(); + self.cfg.jit.apply_env(); // Latch the networking backend the machine is being started with, so the // running status footer can report PCAP vs NAT (and which interface) // independent of any later edits to the config editor. On a build without @@ -637,6 +739,7 @@ impl App { } else { (iris::config::NetMode::Nat, None) }); + self.started_banks = Some(self.cfg.banks); self.emu.send(Cmd::Start(Box::new(self.cfg.clone()))); // Don't resize the window when the VM launches — its size is latched at // app load (the saved window size, or the first-launch fit to vm_scale) @@ -861,9 +964,16 @@ impl App { // Fresh run: re-arm the one-shot PCAP permission prompt so a // pcap-mode machine that can't open its capture re-prompts. self.pcap_perm_prompted = false; + // Stop drops the machine and its 8881 listener; reconnect the + // in-app viewer so it attaches to the new VM, not a dead socket. + if self.serial_console.is_some() { + self.serial_console = Some(serial_console::SerialConsole::connect()); + } self.toast("emulator started"); } - Evt::Stopped => self.toast("emulator stopped"), + 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}")), @@ -989,6 +1099,10 @@ impl App { } ui.close_menu(); } + if ui.button("Prepare for premiere…").clicked() { + self.prepare_for_premiere(); + ui.close_menu(); + } } // App Store sandbox: grant a whole folder (recursive) so the // disk-sync fold — which writes a temp beside the base and @@ -1105,15 +1219,42 @@ impl App { }); 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()); + let running = self.emu.is_running(); + let summary = ram_summary(&self.cfg.banks); + ui.label(RichText::new(format!("Config: {summary}")).strong()); + if running { + if let Some(started) = self.started_banks { + if started != self.cfg.banks { + ui.label( + RichText::new(format!( + "Running: {} (Stop to apply edits)", + ram_summary(&started) + )) + .color(Color32::YELLOW), + ); + } else { + ui.label(RichText::new(format!("Running: {}", ram_summary(&started))).weak()); + } + } + ui.label( + RichText::new("RAM changes apply after Stop → Start") + .weak() + .small(), + ); + } else { + ui.label(RichText::new("Applied at next Start").weak().small()); + } ui.separator(); ui.label("Quick presets (auto-distributed):"); - for &p in &[32u32, 64, 96, 128, 192, 256] { - if ui.button(format!("{p} MB")).clicked() { + for &p in RAM_PRESETS { + if ui + .add_enabled(!running, egui::Button::new(format!("{p} MB"))) + .on_disabled_hover_text("Stop the VM to change RAM") + .clicked() + { self.cfg.banks = distribute_ram(p); self.mark_dirty(); - self.toast(format!("RAM set to {p} MB ({:?})", self.cfg.banks)); + self.toast(format!("RAM set to {} ({:?})", ram_summary(&self.cfg.banks), self.cfg.banks)); ui.close_menu(); } } @@ -1122,7 +1263,11 @@ impl App { 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() { + if ui + .add_enabled(!running, egui::Button::new(format!("{sz} MB"))) + .on_disabled_hover_text("Stop the VM to change RAM") + .clicked() + { self.cfg.banks[i] = sz; self.mark_dirty(); ui.close_menu(); @@ -1138,6 +1283,54 @@ impl App { scsi_menu::ScsiAction::CreateBlank { id } => { self.create_disk.open_for(id); } + scsi_menu::ScsiAction::InsertDisc { id, path } => { + if let Some(msg) = scsi_menu::apply( + &mut self.cfg, + scsi_menu::ScsiAction::InsertDisc { id, path: path.clone() }, + ) { + self.mark_dirty(); + self.toast(msg); + } + if self.emu.is_running() { + self.emu.send(Cmd::LoadDisc { id, path, remount: true }); + } else { + self.toast("Disc saved — Stop→Start to load into SCSI drive"); + } + } + scsi_menu::ScsiAction::AttachCdromWithDisc { id, path } => { + if let Some(msg) = scsi_menu::apply( + &mut self.cfg, + scsi_menu::ScsiAction::AttachCdromWithDisc { id, path: path.clone() }, + ) { + self.mark_dirty(); + self.toast(msg); + } + if self.emu.is_running() { + self.emu.send(Cmd::LoadDisc { id, path, remount: true }); + } + } + scsi_menu::ScsiAction::Eject { id } => { + if let Some(msg) = scsi_menu::apply( + &mut self.cfg, + scsi_menu::ScsiAction::Eject { id }, + ) { + self.mark_dirty(); + self.toast(msg); + } + if self.emu.is_running() { + self.emu.send(Cmd::EjectCdrom { id }); + } + } + scsi_menu::ScsiAction::RemountInIrix { id } => { + if self.emu.is_running() { + self.emu.send(Cmd::RemountCdrom { id }); + self.toast(format!( + "SCSI #{id}: remount sent — keep a shell focused on the console" + )); + } else { + self.toast("Mount /CDROM: start the VM first"); + } + } other => { if let Some(msg) = scsi_menu::apply(&mut self.cfg, other) { self.mark_dirty(); @@ -1520,7 +1713,11 @@ impl App { }; ui.label(status); if running && !halted { - ui.label(format!("{:.0} MIPS", self.emu.status.mips)); + ui.label(format!("{:.0} MIPS", self.emu.status.mips)) + .on_hover_text( + "MIPS = instructions per wall-clock second on your PC (real emulation speed).\n\ + IRIX System Manager \"MHz\" from hinv is inventory from the PROM — not host performance.", + ); } // Networking indicator — ONE badge that shows both liveness (the dot's // colour) and the active backend (the label: NAT / PCAP). Grey while @@ -1648,6 +1845,107 @@ impl App { /// 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) { + if self.cfg.graphics.heads == 2 { + self.framebuffer_panel_dual(ui); + return; + } + self.framebuffer_panel_single(ui); + } + + /// Dual Newport heads: side-by-side viewports (head 0 captures input). + fn framebuffer_panel_dual(&mut self, ui: &mut egui::Ui) { + let seq0 = self.emu.frame_sink.seq(); + let seq1 = self.emu.frame_sink_head1.seq(); + if seq0 == 0 && seq1 == 0 { + ui.centered_and_justified(|ui| { + ui.label(RichText::new("Emulator running — waiting for first REX3 frame…") + .color(Color32::LIGHT_GRAY)); + }); + return; + } + ui.columns(2, |cols| { + cols[0].vertical(|ui| { + ui.label(RichText::new("Head 0").small().weak()); + if seq0 > 0 { + Self::upload_fb_texture(ui, &self.emu.frame_sink, &mut self.fb_tex, &mut self.last_fb_seq, "rex3_fb"); + if let Some(tex) = &self.fb_tex { + let size = fb_fit_size(ui.available_size(), tex.size_vec2()); + ui.centered_and_justified(|ui| { + let response = ui.add( + egui::Image::new((tex.id(), size)) + .fit_to_exact_size(size) + .sense(egui::Sense::click()), + ); + if response.clicked() { + response.request_focus(); + self.input_state.captured = true; + } + }); + } + } else { + ui.label(RichText::new("waiting…").weak()); + } + }); + cols[1].vertical(|ui| { + ui.label(RichText::new("Head 1").small().weak()); + if seq1 > 0 { + Self::upload_fb_texture( + ui, + &self.emu.frame_sink_head1, + &mut self.fb_tex_head1, + &mut self.last_fb_seq_head1, + "rex3_fb_h1", + ); + if let Some(tex) = &self.fb_tex_head1 { + let size = fb_fit_size(ui.available_size(), tex.size_vec2()); + ui.centered_and_justified(|ui| { + ui.add(egui::Image::new((tex.id(), size)).fit_to_exact_size(size)); + }); + } + } else { + ui.label(RichText::new("waiting…").weak()); + } + }); + }); + } + + fn upload_fb_texture( + ui: &egui::Ui, + sink: &crate::framebuffer::FrameSink, + tex_slot: &mut Option, + last_seq: &mut u64, + tex_id: &str, + ) { + let seq = sink.seq(); + if seq == 0 { return; } + let want_nearest = match tex_slot.as_ref().map(|t| t.size_vec2()) { + Some(px) if px.x >= 1.0 && px.y >= 1.0 => { + let scale = ui.available_size().y * ui.ctx().pixels_per_point() / px.y; + is_integer_scale(scale) + } + _ => true, + }; + if tex_slot.is_none() || seq != *last_seq { + let frame = sink.snapshot(); + if frame.width == 0 || frame.height == 0 { return; } + let opts = if want_nearest { + egui::TextureOptions::NEAREST + } else { + egui::TextureOptions::LINEAR + }; + let img = egui::ColorImage::from_rgba_unmultiplied( + [frame.width, frame.height], + &frame.rgba, + ); + match tex_slot { + Some(t) => t.set(img, opts), + None => *tex_slot = Some(ui.ctx().load_texture(tex_id, img, opts)), + } + *last_seq = frame.seq; + } + } + + fn framebuffer_panel_single(&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 @@ -1685,21 +1983,46 @@ impl App { if self.fb_tex.is_none() || seq != self.last_fb_seq || want_nearest != self.fb_nearest { 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); + let opts = if want_nearest { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; - match &mut self.fb_tex { - Some(t) => t.set(img, opts), - None => { - self.fb_tex = Some(ui.ctx().load_texture("rex3_fb", img, opts)); + + let partial = frame.dirty_h > 0 + && frame.dirty_h < frame.height as u32 + && self.fb_tex.is_some(); + + if partial { + let y = frame.dirty_y as usize; + let h = frame.dirty_h as usize; + let w = frame.width; + let start = y * w * 4; + let end = (y + h) * w * 4; + if end <= frame.rgba.len() { + let partial_img = egui::ColorImage::from_rgba_unmultiplied( + [w, h], + &frame.rgba[start..end], + ); + if let Some(tex) = &mut self.fb_tex { + tex.set_partial([0, y], partial_img, opts); + } + self.last_fb_seq = frame.seq; + self.fb_nearest = want_nearest; } + } else { + let img = egui::ColorImage::from_rgba_unmultiplied( + [frame.width, frame.height], &frame.rgba); + match &mut self.fb_tex { + Some(t) => t.set(img, opts), + None => { + self.fb_tex = Some(ui.ctx().load_texture("rex3_fb", img, opts)); + } + } + self.last_fb_seq = frame.seq; + self.fb_nearest = want_nearest; } - self.last_fb_seq = frame.seq; - self.fb_nearest = want_nearest; } // Consume the snap request before the immutable borrow of self.fb_tex. @@ -1874,15 +2197,29 @@ impl App { } } - let out = show_tab(ui, self.tab, &mut self.cfg, &mut self.jit, &self.net_ifaces, &self.prefs.disk_folders, &self.pcap_ifaces); + let banks_before = self.cfg.banks; + let jit_before = self.cfg.jit.clone(); + let cfg_before = toml::to_string(&self.cfg).unwrap_or_default(); + let out = show_tab( + ui, + self.tab, + &mut self.cfg, + &self.net_ifaces, + &self.prefs.disk_folders, + &self.pcap_ifaces, + MemoryUiContext { + running: self.emu.is_running(), + started_banks: self.started_banks, + }, + ); match out.action { ConfigAction::RequestEmbeddedProm => self.confirm_embedded_prom = true, ConfigAction::TestCamera => self.open_camera_test(), ConfigAction::RefreshPcapIfaces => self.refresh_pcap_ifaces(), ConfigAction::EnablePacketCapture => self.run_enable_packet_capture(), - ConfigAction::LoadDisc { id, path } => { + ConfigAction::LoadDisc { id, path, remount } => { if self.emu.is_running() { - self.emu.send(Cmd::LoadDisc { id, path: path.clone() }); + self.emu.send(Cmd::LoadDisc { id, path: path.clone(), remount }); let filename = std::path::Path::new(&path) .file_name() .map(|n| n.to_string_lossy().into_owned()) @@ -1890,14 +2227,25 @@ impl App { self.toast(format!("SCSI #{}: loaded {}", id, filename)); } } + ConfigAction::TestCi => self.test_ci_launch(), + ConfigAction::RebuildProfile { gui } => self.spawn_rebuild_profile(gui), ConfigAction::None => {} } if out.disks_changed { self.mark_dirty(); } + if self.cfg.banks != banks_before { + self.mark_dirty(); + } + if self.cfg.jit != jit_before { + self.mark_dirty(); + } // A disk image was just picked: check up front whether its folder is // grantable, so the user handles permissions at assignment time rather // than discovering at exit that the disk can't be compacted. if out.disk_picked { self.check_chd_folder_grants(); } if out.net.changed { self.mark_dirty(); } + if toml::to_string(&self.cfg).unwrap_or_default() != cfg_before { + self.mark_dirty(); + } if out.net.forwards_changed && self.emu.is_running() { // Rebind the running NAT's listeners so a forward added/removed now // takes effect without a restart (latest-wins coalesces in the NAT). @@ -2216,8 +2564,8 @@ impl App { ui.label(RichText::new("The serial console is NOT the network").strong()); ui.label("• Help > Diagnostics > Serial console is the guest's serial terminal — for login"); ui.label(" and the PROM monitor — and works the same with or without guest networking."); - ui.label("• It's carried over host loopback TCP (127.0.0.1:8881 = console, 8888 = PROM"); - ui.label(" monitor) only as the in-app viewer's transport; that loopback is not the"); + ui.label("• It's carried over host loopback TCP (127.0.0.1:8881 = guest serial / ttyd1;"); + ui.label(" 8888 is the IRIS debug monitor, not the guest console). That loopback is not the"); ui.label(" guest's network connection."); ui.add_space(6.0); ui.label(RichText::new("Guest IP & subnets").strong()); @@ -2588,15 +2936,19 @@ impl App { ui.add_space(4.0); let name = self.prefs.active_machine.as_deref().unwrap_or("(unsaved)"); - ui.label(format!("Machine: {name}")); + let profile = self.cfg.machine.profile.label(); + ui.label(format!("Machine: {name} · {profile}")); 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(); + let ram_line = ram_summary(&self.cfg.banks); ui.label(RichText::new("Machine summary").strong()); egui::Grid::new("summary_grid").num_columns(2).striped(true).show(ui, |ui| { + ui.label("Platform"); + ui.label(self.cfg.machine.profile.label()); + ui.end_row(); ui.label("PROM"); ui.label(if std::path::Path::new(&self.cfg.prom).exists() { abs_path(&self.cfg.prom) @@ -2607,9 +2959,16 @@ impl App { ui.label("NVRAM"); ui.label(abs_path(&self.cfg.nvram)); ui.end_row(); - ui.label("RAM"); - ui.label(format!("{total_ram} MB ({:?})", self.cfg.banks)); + ui.label("RAM (config)"); + ui.label(ram_line); ui.end_row(); + if let Some(started) = self.started_banks { + if started != self.cfg.banks { + ui.label("RAM (last Start)"); + ui.label(ram_summary(&started)); + ui.end_row(); + } + } ui.label("Drives"); ui.vertical(|ui| { let mut ids: Vec = self.cfg.scsi.keys().copied().collect(); @@ -2665,7 +3024,11 @@ impl App { self.run_state_label(ui); ui.separator(); let name = self.prefs.active_machine.as_deref().unwrap_or("(unsaved)"); - ui.label(format!("Machine: {name}{}", if self.cfg_dirty { " *" } else { "" })); + let profile = self.cfg.machine.profile.label(); + ui.label(format!( + "Machine: {name} · {profile}{}", + if self.cfg_dirty { " *" } else { "" } + )); ui.label(format!("Dirty COW: {}", self.emu.status.dirty_cow)); if let Some((msg, when)) = self.toast.clone() { if when.elapsed().as_secs() < 5 { @@ -2745,7 +3108,7 @@ impl eframe::App for App { if let Some(id) = cdrom_id { if let Some(path) = scsi_menu::pick_iso("Load CD-ROM disc") { - self.emu.send(Cmd::LoadDisc { id, path }); + self.emu.send(Cmd::LoadDisc { id, path, remount: true }); } } else { self.toast("No CD-ROM drive attached"); @@ -2815,7 +3178,15 @@ impl eframe::App for App { if self.pending_launcher_fit && ui.ctx().input(|i| i.viewport().monitor_size).is_some() { - Self::snap_window_to_fb(ui.ctx(), egui::vec2(1280.0, 1024.0), ui.available_size(), self.prefs.vm_scale); + Self::snap_window_to_fb( + ui.ctx(), + egui::vec2( + self.cfg.graphics.host_display_size().0 as f32, + self.cfg.graphics.host_display_size().1 as f32, + ), + ui.available_size(), + self.prefs.vm_scale, + ); self.pending_launcher_fit = false; } // Emulator not running: make sure a leftover mouse capture is diff --git a/iris-gui/src/ram.rs b/iris-gui/src/ram.rs new file mode 100644 index 0000000..c3e6d88 --- /dev/null +++ b/iris-gui/src/ram.rs @@ -0,0 +1,14 @@ +//! RAM bank helpers shared by the Memory tab, menus, and status readouts. + +/// Quick preset totals (MB) for the Memory menu and new-machine dialog. +pub const RAM_PRESETS: &[u32] = &[32, 64, 96, 128, 192, 256, 384, 512]; + +pub fn active_banks(banks: &[u32; 4]) -> usize { + banks.iter().filter(|&&s| s > 0).count() +} + +pub fn ram_summary(banks: &[u32; 4]) -> String { + let total: u32 = banks.iter().sum(); + let n = active_banks(banks); + format!("{total} MB ({n} bank{})", if n == 1 { "" } else { "s" }) +} diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index e8c740e..f3530c4 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -8,8 +8,10 @@ pub enum ScsiAction { None, AttachHdd { id: u8, path: String }, AttachEmptyCdrom { id: u8 }, + AttachCdromWithDisc { id: u8, path: String }, InsertDisc { id: u8, path: String }, Eject { id: u8 }, + RemountInIrix { id: u8 }, Detach { id: u8 }, CreateBlank { id: u8 }, ToggleOverlay { id: u8 }, @@ -39,6 +41,12 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { action = ScsiAction::AttachEmptyCdrom { id }; ui.close_menu(); } + if ui.button("Attach CD-ROM with disc…").clicked() { + if let Some(p) = pick_iso("Attach CD-ROM with disc") { + action = ScsiAction::AttachCdromWithDisc { id, path: p }; + } + ui.close_menu(); + } if ui.button("Create blank HDD image…").clicked() { action = ScsiAction::CreateBlank { id }; ui.close_menu(); @@ -59,6 +67,12 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { } ui.close_menu(); } + if has_media { + if ui.button("Mount /CDROM in IRIX…").clicked() { + action = ScsiAction::RemountInIrix { id }; + ui.close_menu(); + } + } ui.separator(); if ui.button("Detach CD-ROM drive").clicked() { action = ScsiAction::Detach { id }; @@ -93,7 +107,8 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { } ui.separator(); ui.label(RichText::new( - "Reset the machine after attaching or detaching drives." + "CD-ROM: prefer SCSI #4. Insert/Swap hot-loads media + remounts /CDROM \ + (console shell must be active). New drives need Stop→Start." ).weak().small()); action } @@ -162,7 +177,14 @@ pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option { path: String::new(), discs: vec![], cdrom: true, overlay: false, scratch: false, size_mb: None, }); - Some(format!("scsi{id}: empty CD-ROM drive attached")) + Some(format!("scsi{id}: empty CD-ROM drive attached (Stop→Start if VM is running)")) + } + ScsiAction::AttachCdromWithDisc { id, path } => { + cfg.scsi.insert(id, ScsiDeviceConfig { + path: path.clone(), discs: vec![], cdrom: true, + overlay: false, scratch: false, size_mb: None, + }); + Some(format!("scsi{id}: CD-ROM attached with disc")) } ScsiAction::InsertDisc { id, path } => { if let Some(d) = cfg.scsi.get_mut(&id) { d.path = path; } @@ -184,5 +206,6 @@ pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option { if let Some(d) = cfg.scsi.get_mut(&id) { d.overlay = !d.overlay; } Some(format!("scsi{id}: overlay toggled")) } + ScsiAction::RemountInIrix { .. } => None, } } diff --git a/iris-gui/src/settings.rs b/iris-gui/src/settings.rs index 97252db..df64933 100644 --- a/iris-gui/src/settings.rs +++ b/iris-gui/src/settings.rs @@ -1,4 +1,5 @@ use iris::config::MachineConfig; +use iris::config::JitConfig; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -55,6 +56,10 @@ pub struct GuiSettings { /// Store build. See [`crate::macos_sandbox`]. #[serde(default)] pub disk_folders: Vec, + + /// Persisted JIT / capture settings (Debug tab). Applied via env at Start. + #[serde(default)] + pub jit: crate::config_ui::JitEnv, } /// Byte offset of the Indy's 6-byte Ethernet MAC inside the NVRAM. The PROM @@ -269,6 +274,16 @@ impl GuiSettings { for m in s.machines.values_mut() { Self::migrate_nvram_path(&mut m.nvram); } + // Legacy top-level jit → active machine [jit] section. + if !s.jit.iris_jit && s.jit == crate::config_ui::JitEnv::default() { + // nothing to migrate + } else if let Some(name) = s.active_machine.clone() { + if let Some(m) = s.machines.get_mut(&name) { + if m.jit == JitConfig::default() { + m.jit = JitConfig::from(&s.jit); + } + } + } s } diff --git a/irix-install/iris-indigo2-smoke-ci.toml b/irix-install/iris-indigo2-smoke-ci.toml new file mode 100644 index 0000000..579c572 --- /dev/null +++ b/irix-install/iris-indigo2-smoke-ci.toml @@ -0,0 +1,38 @@ +# Headless Indigo2 IP22 smoke config — disk/NVRAM under irix-install/. +# Disk: irix-install/scsi1.raw (20 GB IRIX root + overlay) +# Use: iris.exe --config irix-install/iris-indigo2-smoke-ci.toml +# Verify: monitor mc status → SYSID 00000010; ioc status → sys_id=11 + +headless = true +ci = true +ci_socket = "127.0.0.1:19851" +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" +scale = 1 + +[machine] +profile = "indigo2_ip22" + +[graphics] +heads = 1 + +[jit] +enabled = true +probe = 500 +probe_min = 100 +max_tier = 2 +compile_stores = false + +[perf] +thread_affinity = false + +[audio] +prebuf_ms = 20 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false + +[nfs] +shared_dir = "shared" diff --git a/irix-install/iris-install-wsl.toml b/irix-install/iris-install-wsl.toml new file mode 100644 index 0000000..cdb5c7f --- /dev/null +++ b/irix-install/iris-install-wsl.toml @@ -0,0 +1,27 @@ +# WSL/Linux paths (Windows C: drive via /mnt/c) +headless = false +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" + +[scsi.1] +path = "/mnt/c/Users/chron/CURSOR-PROJECTS/iris-main/irix-install/scsi1.raw" +cdrom = false + +[scsi.4] +path = "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso" +cdrom = true +discs = [ + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso", +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" diff --git a/irix-install/iris-install.toml b/irix-install/iris-install.toml new file mode 100644 index 0000000..de7bfad --- /dev/null +++ b/irix-install/iris-install.toml @@ -0,0 +1,34 @@ +# IRIX 6.5 (June 1998) install config for IRIS on Windows. +# ISOs live in C:\Users\chron\Downloads\sgi-irix-6.5 +# Launch from the iris-main repo root: +# cargo +nightly-x86_64-pc-windows-msvc run --release --bin iris --features lightning,rex-jit -- --config irix-install/iris-install.toml + +headless = false +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" + +# Empty 20 GB disk (created with fsutil). Use overlay during install if you like: +# overlay = true → writes go to scsi1.raw.overlay; delete overlay to reset. +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false + +# CD changer at SCSI 4. Installation Tools must be first (boot / miniroot disc). +[scsi.4] +path = 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso' +cdrom = true +discs = [ + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso', +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" diff --git a/irix-install/iris-smoke-ci.toml b/irix-install/iris-smoke-ci.toml new file mode 100644 index 0000000..1a1d8d5 --- /dev/null +++ b/irix-install/iris-smoke-ci.toml @@ -0,0 +1,31 @@ +# Ephemeral CI smoke config (generated for Phase 3 verification). +headless = true +ci = true +ci_socket = "127.0.0.1:19851" +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" +scale = 1 + +[machine] +profile = "indy_ip24" + +[jit] +enabled = true +probe = 500 +probe_min = 100 +max_tier = 2 +compile_stores = false + +[perf] +thread_affinity = false + +[audio] +prebuf_ms = 20 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false + +[nfs] +shared_dir = "shared" diff --git a/irix-install/iris-windows-384.toml b/irix-install/iris-windows-384.toml new file mode 100644 index 0000000..0ab9ed8 --- /dev/null +++ b/irix-install/iris-windows-384.toml @@ -0,0 +1,52 @@ +# IRIX 6.5 stability preset — 384 MB extended RAM (recommended over 512 MB on 6.5). +# Launch: wsl\run-iris-premiere.bat with --config irix-install\iris-windows-384.toml +# Stop iris and cold-start after switching from another banks layout. + +headless = false +no_audio = false +banks = [128, 128, 64, 64] +nvram = "irix-install/nvram-irix65.bin" +scale = 1 + +[machine] +profile = "indy_ip24" + +[jit] +enabled = true +probe = 500 +probe_min = 100 +max_tier = 1 +compile_stores = false +gui_gl_capture = false + +[perf] +thread_affinity = false + +[audio] +prebuf_ms = 40 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false +overlay = true + +[scsi.4] +path = 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso' +cdrom = true +discs = [ + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso', +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" + +[nfs] +shared_dir = "shared" diff --git a/irix-install/iris-windows-safe.toml b/irix-install/iris-windows-safe.toml new file mode 100644 index 0000000..75e67df --- /dev/null +++ b/irix-install/iris-windows-safe.toml @@ -0,0 +1,51 @@ +# Stable premiere config: Loads-tier JIT only (see wsl\run-iris-premiere-safe.bat). +# Same hardware as iris-windows.toml; use that file for max Full-tier performance. + +headless = false +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" +scale = 1 + +[machine] +profile = "indy_ip24" + +[jit] +enabled = true +probe = 500 +probe_min = 100 +max_tier = 1 +compile_stores = false +gui_gl_capture = false + +[perf] +thread_affinity = false + +[audio] +prebuf_ms = 40 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false +overlay = true + +[scsi.4] +path = 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso' +cdrom = true +discs = [ + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso', +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" + +[nfs] +shared_dir = "shared" diff --git a/irix-install/iris-windows.toml b/irix-install/iris-windows.toml new file mode 100644 index 0000000..a33d059 --- /dev/null +++ b/irix-install/iris-windows.toml @@ -0,0 +1,70 @@ +# IRIS daily-use config for native Windows. +# Launch: wsl\run-iris-premiere.bat or wsl\run-iris-windows.bat +# Setup in GUI: wsl\run-iris-gui-windows.bat +# +# Authentic Indy max: banks = [128, 128, 0, 0] (256 MB) +# IRIX 6.5 extended: banks = [128, 128, 64, 64] (384 MB — see iris-windows-384.toml) +# IRIX 5.3 only: banks = [128, 128, 128, 128] (512 MB — NOT for IRIX 6.5) +# After changing banks: Stop iris completely, then cold-start (not hot reload). +# Verify in monitor: mc status | in IRIX: hinv -t memory +# R5000SC build: see wsl/README.md (requires --features iris/r5k,iris/r5ksc rebuild) + +headless = false +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" +scale = 1 + +[machine] +profile = "indy_ip24" + +[jit] +enabled = true +probe = 500 +probe_min = 100 +max_tier = 1 +compile_stores = false +gui_gl_capture = false + +[perf] +thread_affinity = false +# cpu_core = 0 +# rex3_core = 1 +# refresh_core = 2 + +# CI automation (same hardware as GUI export): +# ci = true +# ci_socket = "127.0.0.1:19851" +# headless = true + +[audio] +# Match IRIX BRES master (44100 Hz). Bump prebuf if you hear tail scratch on Windows. +prebuf_ms = 40 +# cpal_buffer_frames = 512 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false +# Protect root disk from kernel-panic corruption (see rules/testing/disk-image-hygiene.md). +overlay = true + +[scsi.4] +path = 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso' +cdrom = true +discs = [ + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso', + 'C:/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso', +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" + +[nfs] +shared_dir = "shared" diff --git a/irix-install/iris-wsl.toml b/irix-install/iris-wsl.toml new file mode 100644 index 0000000..1e02fac --- /dev/null +++ b/irix-install/iris-wsl.toml @@ -0,0 +1,35 @@ +# IRIS daily-use config for WSL (~/iris-wsl-build). +# Disk + NVRAM live in irix-install/ next to this file. +# ISO paths use /mnt/c so CDs stay on the Windows Downloads folder. + +headless = false +no_audio = false +banks = [128, 128, 0, 0] +nvram = "irix-install/nvram-irix65.bin" +scale = 2 + +[scsi.1] +path = "irix-install/scsi1.raw" +cdrom = false + +# Optional CD changer — only needed to install software or swap discs. +[scsi.4] +path = "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso" +cdrom = true +discs = [ + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-installation-tools-june-1998_812-0758-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-1_812-0759-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-foundation-2_812-0760-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-applications-june-1998_812-0761-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-foundation_812-0757-002.iso", + "/mnt/c/Users/chron/Downloads/sgi-irix-6.5/sgi-irix-6.5-development-libraries_812-0766-002.iso", +] + +[[port_forward]] +proto = "tcp" +host_port = 2323 +guest_port = 23 +bind = "localhost" + +[nfs] +shared_dir = "shared" diff --git a/rules/gui/cdrom-hot-insert-remount.md b/rules/gui/cdrom-hot-insert-remount.md new file mode 100644 index 0000000..beff196 --- /dev/null +++ b/rules/gui/cdrom-hot-insert-remount.md @@ -0,0 +1,40 @@ +# GUI CD-ROM insert must hot-load + remount /CDROM + +**Keywords:** gui, cdrom, iso, /CDROM, mediad, scsi menu, hot-swap +**Category:** gui + +## Symptom + +User picks an ISO in iris-gui (SCSI menu or config path) but `/CDROM` stays +empty and no desktop icon appears. + +## Cause + +1. **SCSI menu "Insert disc"** only updated `MachineConfig` — it did not call + `Wd33c93a::load_disc` on the running VM. The emulator still had an empty tray. +2. **IRIX `mediad`** does not reliably remount `/CDROM` after hot insert or + changer swap (`rules/irix/cdrom-changer-eject-no-mediad-remount.md`). + +Config-tab path picker and Ctrl+F12 already hot-loaded; SCSI menu did not. + +## Fix (iris-gui) + +- `Insert disc` / `Swap disc` → `Cmd::LoadDisc { remount: true }` when running +- `Mount /CDROM in IRIX` → `Cmd::RemountCdrom` (injects csh mount one-liner on tty1) +- `Attach CD-ROM with disc…` — attach config + hot-load if running +- `Machine::remount_cdrom_guest(id)` — EFS `dks0dNs7` then iso9660 `dks0dNvol` + +## User workflow + +1. Attach CD-ROM at **SCSI ID 4** (internal) — with disc, or empty + Insert disc +2. If VM already running: Insert disc hot-loads; keep a **shell/xterm** on the + console so remount commands are received +3. Manual fallback in IRIX: + +```csh +mount -t efs -o ro /dev/dsk/dks0d4s7 /CDROM +# or +mount -t iso9660 /dev/rdsk/dks0d4vol /CDROM +``` + +4. New SCSI **drive** attach/detach still requires **Stop → Start** diff --git a/rules/gui/machine-profile-vs-guest-ip22.md b/rules/gui/machine-profile-vs-guest-ip22.md new file mode 100644 index 0000000..dec6450 --- /dev/null +++ b/rules/gui/machine-profile-vs-guest-ip22.md @@ -0,0 +1,32 @@ +# Machine profile vs guest "IP22" in Software Manager + +**Keywords:** machine,profile,ip22,ip24,indy,indigo2,guinness,software manager,hinv + +## Naming (not a bug) + +| Name | Where | Meaning | +|------|-------|---------| +| **IP24** | GUI Platform, TOML `[machine] profile = "indy_ip24"` | SGI Indy board (Guinness) product designation | +| **IP22** | IRIX Software Manager, `hinv` | Kernel platform family for IP22-class machines (Indy, Indigo2, …) | +| **indy** | GUI status bar | Config preset **filename**, not hardware type | + +On a real Indy running IRIX 6.5, Software Manager and `hinv` still report **IP22** as the platform family (e.g. "IP22 Processor"). Selecting **SGI Indy (IP24)** in the GUI does not mean IRIX will say "IP24" in inventory. + +## Config is enforced at `Machine::new` + +`[machine] profile` drives the `guinness` flag passed to MC, IOC, and HPC3: + +- `indy_ip24` → `guinness = true`, MC SYSID `0x00000013` +- `indigo2_ip22` → `guinness = false`, MC SYSID `0x00000010` + +Both profiles are supported in the default build (GUI platform dropdown and CI use the same binary). + +## Verify after changing profile + +1. Monitor console (`127.0.0.1:8888`): `mc status` → SYSID `00000013` on Indy IP24; `00000010` on Indigo2 IP22. +2. IRIX guest: `hinv | head -20` → IP22-family processor line + R4400; Indy-appropriate devices (single Newport, not dual-head Indigo2). +3. TOML with `profile = "indigo2_ip22"` → Start succeeds; hardware is fullhouse, not Guinness. + +## Window title + +`emulator_name()` may randomize the host window title (e.g. "Incredible Rust Indy Simulator"). That string is unrelated to guest inventory. diff --git a/rules/irix/extended-ram-memcfg.md b/rules/irix/extended-ram-memcfg.md new file mode 100644 index 0000000..21d4e75 --- /dev/null +++ b/rules/irix/extended-ram-memcfg.md @@ -0,0 +1,28 @@ +# Extended RAM — MEMCFG synthesis for himem banks + +IRIS can allocate banks 2–3 (himem at `0x20000000` / `0x28000000`) from +`banks` in config, but the embedded Indy PROM (`070-9101-011.bin`) often POSTs +only lomem (banks 0–1). Without valid MEMCFG halves for banks 2–3, IRIX stays +at 256 MB even when the GUI shows 384 or 512 MB. + +## Fix (automatic) + +After each MEMCFG0/1 write, if banks 0–1 are valid (VLD=1) and configured +himem banks are still invalid, `MemoryController::synthesize_himem_banks` patches +MEMCFG1 and fires the remap callback. See `src/mc.rs`. + +## Verify + +1. Set `banks = [128, 128, 64, 64]` (384 MB for IRIX 6.5), Stop → Start. +2. Monitor telnet `127.0.0.1:8888`: `mc status` — banks 2–3 should show VLD=1. +3. In IRIX: `hinv -t memory` or System Manager → About This System. + +## Layout reference + +| banks | Guest (typical) | +|-------|-----------------| +| `[128,128,0,0]` | 256 MB (authentic Indy max) | +| `[128,128,64,64]` | 384 MB (IRIX 6.5) | +| `[128,128,128,128]` | 512 MB (IRIX 5.3 / emulator max) | + +Guest RAM = sum of banks with MEMCFG VLD=1 after boot, not the config total alone. diff --git a/rules/irix/vino-verification-checklist.md b/rules/irix/vino-verification-checklist.md new file mode 100644 index 0000000..786b50d --- /dev/null +++ b/rules/irix/vino-verification-checklist.md @@ -0,0 +1,42 @@ +# VINO / IndyCam verification checklist + +Consolidates guidance from `rules/irix/vino-*.md`. + +## Prerequisites + +- `[vino]` configured in TOML; `camera` feature for host UVC source +- MC SYSID bit 4 set (`rules/irix/vino-attach-via-sysid-bit4.md`) +- GIO alias `0x1F080000` → VINO (`rules/irix/vino-gio-alias-offset.md`) + +## Per-channel routing (implemented) + +- **SELECT_D1 clear:** composite / SAA7191 path → `source_d0` (default black NTSC field) +- **SELECT_D1 set:** IndyCam / CDMC path → `source_d1` (`set_source` from machine) + +## I2C + +- Repeated-start reads: START → addr → subaddr → RE-START → read-addr → READ (`vino.rs` tests) +- Mid-transaction streaming: `I2C_DATA` writes while `NOT_IDLE` set + +## Frame rate mask + +- `CH_FRAME_RATE` write resets `field_counter` so the 12/10-field mask applies from field 0 + +## Manual IRIX tests + +1. `vlinfo` — `vino 0` with 5 nodes +2. IRIX 5.3: `indycam_eoe` / capture per `rules/irix/indycam-end-to-end-capture.md` +3. IRIX 6.5: `videod` + `vl_eoe` / `vino_eoe` per `rules/irix/vino-capture-on-6.5-progress.md` +4. `vino status` in monitor — D0/D1 source lines + +## Known gaps + +- IRIX `impact` kernel module attach and GLX remain unimplemented (preview registers only) +- 6.5 interlace capture may show a thin diagonal artifact (`vino.rs` comments) +- HPC1 region black-holed to avoid capture panic (`physical.rs`) + +## CDMC → VideoSource (implemented) + +- `CdmcAdjustedSource` applies gain, colour balance, saturation, and shutter exposure + to UYVY fields from `source_d1` (`cdmc.rs` → `video_source.rs`) +- Manual IRIX capture still required to close the checklist end-to-end diff --git a/rules/jit/premiere-windows-tlbmiss-not-disk.md b/rules/jit/premiere-windows-tlbmiss-not-disk.md new file mode 100644 index 0000000..0ee3fea --- /dev/null +++ b/rules/jit/premiere-windows-tlbmiss-not-disk.md @@ -0,0 +1,37 @@ +# Premiere Windows TLBMISS at 0xff800000 — usually JIT, not disk + +**Keywords:** premiere, Windows, TLBMISS, ff800000, jit-profile, max_tier, kernel fault +**Category:** jit + +## Symptom + +`run-iris-premiere.bat` panics with `PANIC: TLBMISS: KERNEL FAULT`, bad addr +`0xff800000`, PC in `0x8800xxxx` kernel range. Log shows JIT promoting blocks +(e.g. `8800797c`) to **Full** tier and PC spinning at `88007978–88007984`. + +Same `scsi1.raw` boots fine on Linux GUI with overlay — disk is not the cause. + +## Cause + +Windows premiere enables MIPS JIT with `max_tier=2` (Full) plus a saved +`jit-profile.bin` that replays hot kernel blocks at Full tier. A miscompiled +Full block can issue a bad KSEG3 read → read TLB miss that looks like kernel +corruption. + +COW overlay with `dirty sectors: 0` also rules out filesystem damage from this +session (see `rules/testing/disk-image-hygiene.md` for when panics *are* disk). + +## Workarounds + +1. `wsl\run-iris-premiere-safe.bat` — `IRIS_JIT_MAX_TIER=1`, isolated profile +2. Delete or rename `jit-profile.bin` (project root or `%USERPROFILE%\.iris\`) +3. `set IRIS_JIT=0` before premiere.bat — interpreter-only A/B test +4. Compare: Linux GUI may run without JIT env / lower tier + +## Fix direction + +Profile replay must respect `IRIS_JIT_MAX_TIER` (capped in dispatch.rs). +Profile entries replay at Loads tier max; Full is in-session promotion only. +Load-only blocks at **Full** tier must **never** graduate out of speculative mode at the +stable threshold — graduating Loads/Alu is fine for speed. Premiere defaults to +`max_tier=1` (Loads) on Windows until Full-tier codegen is verified. diff --git a/rules/jit/store-compilation.md b/rules/jit/store-compilation.md index 4812bf8..843c92a 100644 --- a/rules/jit/store-compilation.md +++ b/rules/jit/store-compilation.md @@ -74,3 +74,9 @@ if is_compilable_for_tier(&delay_d, tier) && !delay_can_fault { in_delay_slot. exec.step() re-executes as a non-delay-slot instruction. handle_exception sets cp0_epc to the instruction PC (not the branch PC) and doesn't set the BD bit. On ERET, the branch is permanently skipped. + +## Phase 3 TOML toggle + +`[jit] compile_stores = true` clears `IRIS_JIT_NO_STORES` at startup via `JitConfig::apply_env()`. +Leave **false** (default) until the memory undo-log research branch lands — see isolation matrix above. +When enabled, monitor with `perf snapshot` and GL smoke (`glxgears`, `medialab`). diff --git a/rules/jit/x86_64-loads-helper-limit-silent-quit.md b/rules/jit/x86_64-loads-helper-limit-silent-quit.md new file mode 100644 index 0000000..631725f --- /dev/null +++ b/rules/jit/x86_64-loads-helper-limit-silent-quit.md @@ -0,0 +1,32 @@ +# x86_64 Loads-tier helper limit + speculative graduation + +**Keywords:** x86_64, Loads, silent quit, userspace, helper diamond, speculative +**Category:** jit + +## Symptom + +IRIX boots at 50–80 MIPS; apps launch quickly then **quit with no error**. Same +with premiere (`max_tier=1`) and full-tier experimental launcher. + +## Cause + +On x86_64, Cranelift regalloc2 miscompiles blocks with **more than one** load +helper diamond (`rules/jit/cranelift-regalloc2-helper-diamond-limit-is-platform-dependent.md`). +`trace_block` only limited helpers for Full tier and used `max_helpers=64`. + +Graduating **Loads** blocks out of speculative mode after 50 stable hits removed +rollback/demotion for those miscompiles → silent userspace corruption. + +## Fix (dispatch.rs) + +1. `max_helpers = 1` on x86_64, `3` on aarch64 — applies to **Loads and Full**. +2. `speculative_may_graduate`: Alu yes; Loads only on aarch64; Full never. + +## Verify + +```bat +wsl\run-iris-premiere-nojit.bat # stable → was JIT +wsl\capture-app-crash.ps1 -Verify # JIT VERIFY FAIL pinpoints PC if still broken +``` + +Also check RAM: IRIX 6.5 should use 384 MB (`iris-windows-384.toml`), not 512 MB. diff --git a/rules/perf/gui-idle-refresh.md b/rules/perf/gui-idle-refresh.md new file mode 100644 index 0000000..75c849c --- /dev/null +++ b/rules/perf/gui-idle-refresh.md @@ -0,0 +1,9 @@ +# Status-bar-only heartbeat refresh skips full 16 MB FB copy and (in iris-gui) +# may upload only the status-bar scanlines to egui. + +When `fb_dirty` is false but the idle heartbeat fires (~10 Hz), REX3 sets +`screen.status_bar_only` and capture renderers skip `SwCompositor` / full GL +compose — only the status bar is redrawn. + +Full-frame memcpy and compositor work still run whenever the guest draws to the +framebuffer or palette/cursor state changes. diff --git a/rules/perf/hal2-pump-thread.md b/rules/perf/hal2-pump-thread.md new file mode 100644 index 0000000..84e82e5 --- /dev/null +++ b/rules/perf/hal2-pump-thread.md @@ -0,0 +1,36 @@ +# HAL2 dedicated pump thread — do not use thread::sleep + +**Keywords:** hal2,audio,scratch,stutter,pump,hptimer,sleep,windows + +Phase 3 briefly moved Codec A output from `TimerManager` to a `HAL2-Pump` thread +that called `thread::sleep(period - elapsed)` with `period ≈ 1/44100 s` (~23 µs). + +## Why it failed + +`hptimer` (`src/hptimer.rs`) **spins** for waits under ~200 µs so 44.1 kHz audio +ticks stay on schedule. `thread::sleep` on Windows — even with `timeBeginPeriod(1)` +from `Hal2::start()` — cannot reliably sleep for 23 µs; effective wakeups land near +**1 ms**, so DMA was drained ~40× too slowly. The cpal ring buffer underran constantly +(scratchy/crackling audio). + +Slow PDMA drain may also stress the IRIX audio path; treat audio regressions as +P0 before chasing unrelated kernel panics. + +## Correct approach + +- **Now:** Codec A uses dedicated `HAL2-Pump` thread with hptimer-style spin (no `thread::sleep`). +- **Legacy:** Codec A was on `TimerManager` / `hptimer` before Phase 3 master plan. +- **Future isolation:** If a dedicated thread is retried, it must use the same + spin/park policy as `timer_thread_loop` (spin for `delay < 200 µs`), not sleep. + +## Verify after changes + +Telnet monitor `hal2 status` — `cpal underruns` should stay low during desktop idle +and under `glxgears`. Premiere: `wsl\run-iris-premiere.bat`. + +## Kernel panic after earlier crashes + +If you see `Read TLB Miss` at `0xff800000` repeatedly, the **root disk may be +corrupted** from a prior panic — not a JIT bug. Enable `overlay = true` on +`[scsi.1]` and restore from a clean `scsi1.raw` copy. See +`rules/testing/disk-image-hygiene.md`. diff --git a/rules/perf/hardware-profiles.md b/rules/perf/hardware-profiles.md new file mode 100644 index 0000000..d3c85ba --- /dev/null +++ b/rules/perf/hardware-profiles.md @@ -0,0 +1,36 @@ +# Hardware profiles vs MAME (Phase 3) + +| Capability | MAME Indy | IRIS Phase 3 | +|------------|-----------|--------------| +| IRIX 6.5 desktop | Slow; DRC often flaky | Premiere stack (dual JIT) | +| Config GUI | None | Full `MachineConfig` + export TOML | +| CI automation | External scripts | `iris-ci` + TCP on Windows | +| Extended RAM | Limited | 384/512 MB GUI presets | +| Networking | Yes | NAT + pcap + port forward | +| Audio tuning | Basic | HAL2-Pump spin thread + underrun stats | +| Indigo2 single-head | Yes (slow) | `profile = indigo2_ip22` — default build, no cargo feature | +| Indigo2 dual-head | Yes (slow) | `profile = indigo2_ip22`, `graphics.heads = 2` | + +Profiles in TOML: + +```toml +[machine] +profile = "indy_ip24" # default — enforced at Machine::new (guinness=true) +# profile = "indigo2_ip22" +``` + +`profile` is **not cosmetic**: it sets MC/IOC/HPC3 Guinness layout. IRIX still reports **IP22** as the platform family on Indy — see [`rules/gui/machine-profile-vs-guest-ip22.md`](../gui/machine-profile-vs-guest-ip22.md). + +R4400 vs R5000 remains a **compile-time** Cargo feature — GUI shows rebuild command on Debug tab. + +## RAM presets (stability) + +| IRIX version | Recommended `banks` | Guest RAM | +|--------------|---------------------|-----------| +| 6.5 | `[128, 128, 64, 64]` | 384 MB | +| 6.5 / Indy authentic | `[128, 128, 0, 0]` | 256 MB | +| 5.3 | `[128, 128, 128, 128]` | 512 MB | + +512 MB on IRIX 6.5 is not documented as supported — use 384 MB (`irix-install/iris-windows-384.toml`) if apps quit unexpectedly after a GUI RAM upgrade. + +There is **no guest CPU MHz / overclock** config; status-bar MIPS is host emulation throughput only. diff --git a/rules/perf/phase3-platform.md b/rules/perf/phase3-platform.md new file mode 100644 index 0000000..cd4b0f2 --- /dev/null +++ b/rules/perf/phase3-platform.md @@ -0,0 +1,29 @@ +# Phase 3 platform notes + +## Unified config + +`MachineConfig` now includes `[jit]`, `[perf]`, and `[machine]` TOML sections. CLI applies +`cfg.jit.apply_env()` at startup; iris-gui syncs Debug tab edits into `cfg.jit` before Start. + +## Windows CI + +Default `ci_socket = "127.0.0.1:19851"` (TCP). Unix builds keep `/tmp/iris.sock`. +`iris-ci` connects to either form. Launch headless CI with `wsl/run-iris-ci.bat`. + +## HAL2 + +Codec A output uses the shared **hptimer** (`TimerManager`) recurring timer at the +codec pitch rate (~44.1 kHz). A Phase 3 experiment with `thread::sleep` on a +dedicated pump thread caused scratchy audio on Windows — see +`rules/perf/hal2-pump-thread.md`. Codec B/AES still use hptimer as well. + +## Graphics + +- `Rex3Screen.fb_borrowed`: skip 16 MB copy when refresh uses heartbeat-only FB path. +- `CompositorSource.dirty_y0/y1`: partial GL texture upload for status-bar-only frames. +- GUI: partial egui upload (Phase 2) + live VRAM borrow (Phase 3). + +## JIT stores + +Set `[jit] compile_stores = true` (or uncheck "Disable JIT stores" in GUI) to clear +`IRIS_JIT_NO_STORES`. Write-log rollback remains the safety net per `rules/jit/store-compilation.md`. diff --git a/rules/testing/silent-app-quit-debug.md b/rules/testing/silent-app-quit-debug.md new file mode 100644 index 0000000..b8f50aa --- /dev/null +++ b/rules/testing/silent-app-quit-debug.md @@ -0,0 +1,45 @@ +# Silent IRIX app quit — debug capture checklist + +**Keywords:** userspace, app quit, JIT verify, monitor, SYSLOG, 512 MB, IRIX 6.5 +**Category:** testing + +When IRIX apps close with no error dialog, collect evidence before guessing at fixes. + +## Quick triage + +1. **RAM layout** — IRIX 6.5: use `[128,128,64,64]` (384 MB), not 512 MB (`iris-windows-384.toml`). Verify `hinv -t memory` matches config after cold start. +2. **JIT A/B** — `wsl\run-iris-premiere-nojit.bat` vs `wsl\run-iris-premiere.bat` on the same apps. +3. **Disk** — `cow status` in monitor; reset overlay if dirty sectors grew after panics. + +## Capture bundle (send to developer) + +| Artifact | How | +|----------|-----| +| TOML `banks` | `irix-install/iris-windows.toml` | +| Guest RAM | `hinv -t memory` in IRIX shell | +| Host log | `wsl\capture-app-crash.ps1` → `premiere-debug.log` | +| Monitor | telnet 8888: `stop` → `status` / `regs` / `bt` / `dt 80` | +| Guest | `ps -ef`, `tail -50 /var/adm/SYSLOG` after quit | +| JIT A/B | stable with no-JIT? yes/no | + +## Monitor commands at quit moment + +```text +stop +status +regs +bt +dt 80 +exception all on +cow status +mc status +``` + +Developer build adds `debug on`, `log mips mask insn`, `dt file crash-trace.txt 1048576`. + +## JIT stderr signatures + +- `JIT VERIFY FAIL` / `REAL CODEGEN MISMATCH` — codegen bug at listed PC +- Rising `rollbacks` / `demotions` in `JIT: ... ⟲` lines — speculative path fighting bad blocks + +See also `rules/jit/speculative-safety-net.md`, `rules/testing/disk-image-hygiene.md`. diff --git a/src/cdmc.rs b/src/cdmc.rs index d035f19..58238ac 100644 --- a/src/cdmc.rs +++ b/src/cdmc.rs @@ -5,11 +5,9 @@ /// `videopanel` clients write CDMC registers to adjust brightness, hue, /// saturation, gamma, etc. /// -/// Fake device: register storage + I2C state machine only. No real image -/// processing — VINO upstream gets pixels from the configured `VideoSource` -/// regardless of CDMC settings. Honest stub: lets IRIX drivers probe and -/// configure without errors; visual effect of register changes is not (yet) -/// reflected in the captured pixels. +/// Fake device: register storage + I2C state machine. `apply_uyvy_field` applies +/// gain/balance/saturation/shutter exposure to host camera pixels via +/// `CdmcAdjustedSource` in `video_source.rs`. /// /// I2C address: 0x56 write / 0x57 read (7-bit address 0x2B). /// @@ -207,6 +205,47 @@ impl Cdmc { self.state.lock().i2c_state = I2cState::Idle; } + /// Snapshot of image-control registers for the video pixel pipeline. + pub fn regs_copy(&self) -> [u8; reg::COUNT] { + self.state.lock().regs + } + + /// Apply CDMC brightness / colour balance to a packed UYVY field in-place. + pub fn apply_uyvy_field(pixels: &mut [u8], regs: &[u8; reg::COUNT]) { + // GAIN 0x00..=0xFF maps roughly to 0.5×..1.5× luma; 0x80 = unity. + let gain = regs[reg::GAIN as usize] as i32; + let luma_scale = (gain as f32 - 128.0) / 128.0 + 1.0; + let red_bias = (regs[reg::RED_BAL as usize] as i32 - 128) as f32 / 128.0; + let blue_bias = (regs[reg::BLUE_BAL as usize] as i32 - 128) as f32 / 128.0; + let red_sat = (regs[reg::RED_SAT as usize] as i32 - 128) as f32 / 256.0; + let blue_sat = (regs[reg::BLUE_SAT as usize] as i32 - 128) as f32 / 256.0; + let shutter = ((regs[reg::SHUTTER_HI as usize] as u16) << 8) + | regs[reg::SHUTTER_LO as usize] as u16; + // Shutter 0 = auto; non-zero scales exposure down (preview approximation). + let exposure = if shutter == 0 { + 1.0f32 + } else { + (65535.0 / shutter as f32).clamp(0.25, 2.0) + }; + + for c in pixels.chunks_mut(4) { + let u = c[0] as f32; + let y = c[1] as f32; + let v = c[2] as f32; + let y2 = c[3] as f32; + + let y_adj = ((y - 16.0) * luma_scale * exposure + 16.0).clamp(0.0, 235.0); + let y2_adj = ((y2 - 16.0) * luma_scale * exposure + 16.0).clamp(0.0, 235.0); + let u_adj = (u + blue_bias * 32.0 + blue_sat * (u - 128.0)).clamp(0.0, 255.0); + let v_adj = (v + red_bias * 32.0 + red_sat * (v - 128.0)).clamp(0.0, 255.0); + + c[0] = u_adj as u8; + c[1] = y_adj as u8; + c[2] = v_adj as u8; + c[3] = y2_adj as u8; + } + } + // ── Register write ──────────────────────────────────────────────────── fn reg_w(st: &mut CdmcState, data: u8) { diff --git a/src/ci.rs b/src/ci.rs index a4a6099..a058af3 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -1,17 +1,20 @@ //! CI control socket. //! -//! Unix domain socket that drives the emulator for automated testing. The -//! protocol is newline-delimited JSON, strict request/response, single client. -//! See `ci_mode_plan.md` in the repo root. +//! Unix domain socket (Linux/macOS) or TCP localhost (Windows) that drives the +//! emulator for automated testing. The protocol is newline-delimited JSON, +//! strict request/response, single client. See `ci_mode_plan.md` in the repo root. -#![cfg(unix)] - -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::{UnixListener, UnixStream}; +use std::io::{BufRead, BufReader, Read, Write}; use std::sync::Arc; use std::thread; use std::time::Duration; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; +use std::net::{TcpListener, TcpStream}; + +use crate::config::{ci_socket_is_tcp, ci_socket_tcp_addr}; + use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -66,12 +69,21 @@ pub struct CiServer { /// Optional in case --headless is also passed (no REX3). Screenshot /// commands return an error in that case. rex3: Option>, + rex3_head1: Option>, } impl Drop for CiServer { fn drop(&mut self) { - let _ = std::fs::remove_file(&self.socket_path); + cleanup_socket_path(&self.socket_path); + } +} + +fn cleanup_socket_path(path: &str) { + if ci_socket_is_tcp(path) { + return; } + #[cfg(unix)] + let _ = std::fs::remove_file(path); } impl CiServer { @@ -100,24 +112,40 @@ pub fn start_server( let ci_serial = unsafe { (*machine_ptr).get_ci_serial() } .ok_or_else(|| "CI mode: CiSerialBackend not installed on Machine".to_string())?; let rex3 = unsafe { (*machine_ptr).get_rex3() }; + let rex3_head1 = unsafe { (*machine_ptr).get_rex3_head1() }; let path = socket_path.to_string(); - // Clear stale socket from a previous run. - let _ = std::fs::remove_file(&path); - let listener = UnixListener::bind(&path) - .map_err(|e| format!("failed to bind {}: {}", path, e))?; - - eprintln!("iris: --ci control socket listening at {}", path); - - *SOCKET_PATH.lock() = Some(path.clone()); - let server = Arc::new(CiServer { - socket_path: path, + socket_path: path.clone(), machine: Arc::new(Mutex::new(MachinePtr(machine_ptr))), ci_serial, rex3, + rex3_head1, }); + *SOCKET_PATH.lock() = Some(path.clone()); + + if ci_socket_is_tcp(&path) { + start_tcp_server(server.clone(), &path)?; + } else { + #[cfg(unix)] + start_unix_server(server, &path)?; + #[cfg(not(unix))] + return Err(format!( + "CI socket path {} requires a Unix domain socket; use host:port on this platform", + path + )); + } + + Ok(server) +} + +#[cfg(unix)] +fn start_unix_server(server: Arc, path: &str) -> Result<(), String> { + cleanup_socket_path(path); + let listener = UnixListener::bind(path) + .map_err(|e| format!("failed to bind {}: {}", path, e))?; + eprintln!("iris: --ci control socket listening at {}", path); let server_clone = server.clone(); thread::Builder::new() .name("iris-ci-accept".into()) @@ -136,23 +164,47 @@ pub fn start_server( } }) .map_err(|e| format!("failed to spawn CI accept thread: {}", e))?; + Ok(()) +} - Ok(server) +#[cfg(windows)] +fn start_tcp_server(server: Arc, path: &str) -> Result<(), String> { + start_tcp_server_impl(server, path) } -// ---------------------------------------------------------------------------- -// Connection handling -// ---------------------------------------------------------------------------- +#[cfg(not(windows))] +fn start_tcp_server(server: Arc, path: &str) -> Result<(), String> { + start_tcp_server_impl(server, path) +} -fn handle_client(server: Arc, stream: UnixStream) { - let reader = match stream.try_clone() { - Ok(s) => BufReader::new(s), - Err(e) => { - eprintln!("iris-ci-handler: clone failed: {}", e); - return; - } - }; - let mut writer = stream; +fn start_tcp_server_impl(server: Arc, path: &str) -> Result<(), String> { + let addr = ci_socket_tcp_addr(path); + let listener = TcpListener::bind(&addr) + .map_err(|e| format!("failed to bind TCP CI socket {}: {}", addr, e))?; + eprintln!("iris: --ci control socket listening at tcp:{}", addr); + let server_clone = server.clone(); + thread::Builder::new() + .name("iris-ci-accept".into()) + .spawn(move || { + for conn in listener.incoming() { + match conn { + Ok(stream) => { + let _ = stream.set_nodelay(true); + let s = server_clone.clone(); + thread::Builder::new() + .name("iris-ci-handler".into()) + .spawn(move || handle_client_tcp(s, stream)) + .ok(); + } + Err(e) => eprintln!("iris-ci-accept: {}", e), + } + } + }) + .map_err(|e| format!("failed to spawn CI accept thread: {}", e))?; + Ok(()) +} + +fn handle_client_lines(server: Arc, reader: impl BufRead, mut writer: impl Write) { for line in reader.lines() { let Ok(line) = line else { break }; let trimmed = line.trim(); @@ -172,6 +224,27 @@ fn handle_client(server: Arc, stream: UnixStream) { } } +#[cfg(unix)] +fn handle_client(server: Arc, stream: UnixStream) { + match stream.try_clone() { + Ok(writer) => { + let reader = BufReader::new(stream); + handle_client_lines(server, reader, writer); + } + Err(e) => eprintln!("iris-ci-handler: clone failed: {}", e), + } +} + +fn handle_client_tcp(server: Arc, stream: TcpStream) { + match stream.try_clone() { + Ok(writer) => { + let reader = BufReader::new(stream); + handle_client_lines(server, reader, writer); + } + Err(e) => eprintln!("iris-ci-handler: clone failed: {}", e), + } +} + // ---------------------------------------------------------------------------- // Dispatch // ---------------------------------------------------------------------------- @@ -258,7 +331,7 @@ fn cmd_quit() -> Response { thread::spawn(|| { thread::sleep(Duration::from_millis(50)); if let Some(p) = SOCKET_PATH.lock().take() { - let _ = std::fs::remove_file(&p); + cleanup_socket_path(&p); } // Same escape hatch as the PowerOff handler: library hosts set // IRIS_NO_EXIT_ON_POWEROFF=1 so a `quit` over the CI socket does @@ -428,8 +501,17 @@ fn cmd_serial_read(server: &CiServer) -> Response { } fn cmd_screenshot(server: &CiServer, args: &Value) -> Response { - let Some(rex3) = &server.rex3 else { - return Response::err("screenshot: REX3 not present (running with --headless?)"); + let head = args.get("head").and_then(|v| v.as_u64()).unwrap_or(0); + let rex3 = match head { + 0 => server.rex3.as_ref(), + 1 => server.rex3_head1.as_ref(), + _ => return Response::err("screenshot: head must be 0 or 1"), + }; + let Some(rex3) = rex3 else { + return Response::err(format!( + "screenshot: REX3 head {} not present (headless or graphics.heads < 2?)", + head + )); }; let Some(path) = args.get("path").and_then(|v| v.as_str()) else { return Response::err("screenshot: missing 'path' arg"); @@ -477,6 +559,7 @@ fn cmd_screenshot(server: &CiServer, args: &Value) -> Response { Response::data(serde_json::json!({ "path": path, + "head": head, "width": width, "height": height, "bytes": rgb.len() + 100, // rough diff --git a/src/compositor.rs b/src/compositor.rs index 7b3e274..312dfdb 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -33,6 +33,14 @@ pub struct CompositorSource<'a> { /// Visible display dimensions decoded from VC2 video timings pub width: usize, pub height: usize, + /// Partial upload region (display rows, inclusive y0, exclusive y1). + pub dirty_y0: usize, + pub dirty_y1: usize, + /// Partial upload region (display columns, inclusive x0, exclusive x1). + pub dirty_x0: usize, + pub dirty_x1: usize, + /// Heartbeat: only status-bar rows changed — skip full DID upload. + pub status_bar_only: bool, } /// Compositor: maps hardware source state to a composited GL texture. diff --git a/src/config.rs b/src/config.rs index 25956f5..0e9897d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -205,6 +205,296 @@ pub struct Ultra64Config { pub enabled: bool, } +/// Emulated SGI machine profile (hardware layout scaffold). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MachineProfile { + /// SGI Indy IP24 (Guinness) — single Newport GIO64. Default and fully supported. + #[default] + IndyIp24, + /// SGI Indigo2 IP22 — fullhouse MC/IOC, Newport XL on GIO gfx slot. + Indigo2Ip22, +} + +impl MachineProfile { + pub fn label(self) -> &'static str { + match self { + Self::IndyIp24 => "SGI Indy (IP24)", + Self::Indigo2Ip22 => "SGI Indigo2 (IP22)", + } + } + + pub fn supported(self) -> bool { + matches!(self, Self::IndyIp24 | Self::Indigo2Ip22) + } + + /// MC/IOC/HPC3 Guinness vs Fullhouse layout. Indy IP24 is Guinness (`true`). + pub fn guinness(self) -> bool { + matches!(self, Self::IndyIp24) + } +} + +/// Indy / Indigo2 graphics board family (preview stubs for non-Newport options). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum GraphicsBoard { + /// Newport (REX3) — fully emulated. Default. + #[default] + Newport, + /// Indy GR3-XZ / Elan (HQ2 command engine) — register stub only (`src/xz.rs`). + Xz, +} + +/// IMPACT board occupying one GIO64 slot (Indigo2 preview scaffold). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ImpactSlot { + #[default] + None, + Solid, + High, + Max, +} + +/// `[impact]` section — IMPACT/MGRAS slot population (Indigo2 IP22 preview). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ImpactSection { + /// GIO gfx slot (`0x1F000000`). Solid IMPACT anchors here. + #[serde(default)] + pub gfx: ImpactSlot, + /// GIO expansion slot 0 (`0x1F400000`). Second board for High / Max configs. + #[serde(default)] + pub exp0: ImpactSlot, + /// GIO expansion slot 1 (`0x1F600000`). Third board for Maximum IMPACT. + #[serde(default)] + pub exp1: ImpactSlot, +} + +impl ImpactSection { + pub fn any_enabled(&self) -> bool { + self.gfx != ImpactSlot::None + || self.exp0 != ImpactSlot::None + || self.exp1 != ImpactSlot::None + } + + /// Hardware-valid slot population (rejects High+High and orphan expansion boards). + pub fn validate(&self) -> Result<(), String> { + let slots = [self.gfx, self.exp0, self.exp1]; + let high_count = slots.iter().filter(|&&s| s == ImpactSlot::High).count(); + if high_count >= 2 { + return Err( + "[impact] High+High is invalid — at most one High IMPACT board per system".into(), + ); + } + if self.exp0 != ImpactSlot::None && self.gfx == ImpactSlot::None { + return Err("[impact] exp0 requires gfx slot populated".into()); + } + if self.exp1 != ImpactSlot::None && self.exp0 == ImpactSlot::None { + return Err("[impact] exp1 requires exp0 populated (Maximum IMPACT uses all three slots)".into()); + } + if self.exp1 == ImpactSlot::Max && self.exp0 != ImpactSlot::High { + return Err("[impact] Maximum IMPACT expects exp0=high when exp1=max".into()); + } + Ok(()) + } +} + +/// `[graphics]` section — Newport head count and display options. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GraphicsSection { + /// Graphics board family. `xz` is Indy-only preview; disables Newport compositor. + #[serde(default)] + pub board: GraphicsBoard, + /// Newport heads to emulate (1 or 2). Dual-head maps a second REX3 at GIO slot 1. + #[serde(default = "default_graphics_heads")] + pub heads: u8, + /// Host-forced Newport video mode at VM start (`guest` = IRIX/setmon controls VC2). + #[serde(default)] + pub resolution: crate::vc2_timings::NewportResolution, +} + +impl GraphicsSection { + /// Pixel size for host window layout when a preset is selected. + pub fn host_display_size(&self) -> (u32, u32) { + self.resolution + .visible_size() + .unwrap_or((1280, 1024)) + } +} + +fn default_graphics_heads() -> u8 { 1 } + +impl Default for GraphicsSection { + fn default() -> Self { + Self { + board: GraphicsBoard::default(), + heads: default_graphics_heads(), + resolution: crate::vc2_timings::NewportResolution::default(), + } + } +} + +/// `[machine]` section — platform identity (not performance knobs). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MachineSection { + #[serde(default)] + pub profile: MachineProfile, +} + +impl Default for MachineSection { + fn default() -> Self { + Self { profile: MachineProfile::default() } + } +} + +/// MIPS / REX3 JIT runtime tuning (`[jit]` section). Applied to process env at Start. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct JitConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_jit_probe")] + pub probe: u32, + #[serde(default = "default_jit_probe_min")] + pub probe_min: u32, + #[serde(default = "default_jit_max_tier")] + pub max_tier: u8, + /// iris-gui only: use GlCompositor capture path (IRIS_GUI_GL=1). + #[serde(default)] + pub gui_gl_capture: bool, + /// Allow Full-tier store compilation (uses write-log rollback). Off by default. + #[serde(default)] + pub compile_stores: bool, + #[serde(default)] + pub verify: bool, + #[serde(default)] + pub no_idle: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub trace_file: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub profile_file: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub debug_log: String, +} + +fn default_jit_probe() -> u32 { 500 } +fn default_jit_probe_min() -> u32 { 100 } +fn default_jit_max_tier() -> u8 { 2 } + +impl Default for JitConfig { + fn default() -> Self { + Self { + enabled: false, + probe: default_jit_probe(), + probe_min: default_jit_probe_min(), + max_tier: default_jit_max_tier(), + gui_gl_capture: false, + compile_stores: false, + verify: false, + no_idle: false, + trace_file: String::new(), + profile_file: String::new(), + debug_log: String::new(), + } + } +} + +impl JitConfig { + pub fn premiere_defaults() -> Self { + Self { enabled: true, max_tier: 1, ..Default::default() } + } + + /// Apply to current process environment (CLI and iris-gui before Machine::new). + pub fn apply_env(&self) { + if self.enabled { + std::env::set_var("IRIS_JIT", "1"); + } else { + std::env::remove_var("IRIS_JIT"); + } + std::env::set_var("IRIS_JIT_PROBE", self.probe.to_string()); + std::env::set_var("IRIS_JIT_PROBE_MIN", self.probe_min.to_string()); + std::env::set_var("IRIS_JIT_MAX_TIER", self.max_tier.to_string()); + if self.verify { + std::env::set_var("IRIS_JIT_VERIFY", "1"); + } else { + std::env::remove_var("IRIS_JIT_VERIFY"); + } + if self.compile_stores { + std::env::remove_var("IRIS_JIT_NO_STORES"); + } else { + std::env::set_var("IRIS_JIT_NO_STORES", "1"); + } + if self.no_idle { + std::env::set_var("IRIS_NO_IDLE", "1"); + } else { + std::env::remove_var("IRIS_NO_IDLE"); + } + if self.gui_gl_capture { + std::env::set_var("IRIS_GUI_GL", "1"); + } else { + std::env::remove_var("IRIS_GUI_GL"); + } + set_or_remove_env("IRIS_JIT_TRACE", &self.trace_file); + set_or_remove_env("IRIS_JIT_PROFILE", &self.profile_file); + set_or_remove_env("IRIS_DEBUG_LOG", &self.debug_log); + } +} + +fn set_or_remove_env(key: &str, val: &str) { + if val.is_empty() { + std::env::remove_var(key); + } else { + std::env::set_var(key, val); + } +} + +/// Host-side performance tuning (`[perf]` section). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PerfConfig { + #[serde(default)] + pub thread_affinity: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_core: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rex3_core: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_core: Option, +} + +impl Default for PerfConfig { + fn default() -> Self { + Self { + thread_affinity: false, + cpu_core: None, + rex3_core: None, + refresh_core: None, + } + } +} + +/// HAL2 / cpal audio output tuning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioConfig { + /// Pre-buffer duration (ms) before feeding the cpal ring. Default 20. + #[serde(default = "default_audio_prebuf_ms")] + pub prebuf_ms: u64, + /// Fixed cpal buffer size in frames (stereo pairs). Unset = host default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpal_buffer_frames: Option, +} + +fn default_audio_prebuf_ms() -> u64 { 20 } + +impl Default for AudioConfig { + fn default() -> Self { + Self { prebuf_ms: default_audio_prebuf_ms(), cpal_buffer_frames: None } + } +} + /// VINO video-in configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct VinoConfig { @@ -365,6 +655,30 @@ pub struct MachineConfig { #[serde(default = "default_scsi_deferred_int")] pub scsi_deferred_int: bool, + /// HAL2 audio output tuning (`[audio]` section). + #[serde(default)] + pub audio: AudioConfig, + + /// Machine platform profile (`[machine]` section). + #[serde(default)] + pub machine: MachineSection, + + /// Graphics options (`[graphics]` section). + #[serde(default)] + pub graphics: GraphicsSection, + + /// IMPACT/MGRAS slot population (`[impact]` section, Indigo2 preview). + #[serde(default)] + pub impact: ImpactSection, + + /// JIT runtime tuning (`[jit]` section). + #[serde(default)] + pub jit: JitConfig, + + /// Host performance tuning (`[perf]` section). + #[serde(default)] + pub perf: PerfConfig, + /// N64 development board (Ultra64) — GIO slot 0 + shm IPC. #[cfg(feature = "ultra64")] #[serde(default)] @@ -373,7 +687,38 @@ pub struct MachineConfig { fn default_scsi_deferred_int() -> bool { true } +#[cfg(unix)] +fn default_ci_socket() -> String { "/tmp/iris.sock".to_string() } +#[cfg(windows)] +fn default_ci_socket() -> String { "127.0.0.1:19851".to_string() } +#[cfg(not(any(unix, windows)))] fn default_ci_socket() -> String { "/tmp/iris.sock".to_string() } + +/// True when `ci_socket` is a TCP `host:port` address (Windows CI default). +pub fn ci_socket_is_tcp(path: &str) -> bool { + let path = path.trim(); + if path.starts_with("tcp:") { + return true; + } + // host:port heuristic (not a filesystem path) + if path.contains(':') && !path.starts_with('\\') && !path.starts_with('/') { + path.rsplit_once(':') + .map(|(_, port)| port.parse::().is_ok()) + .unwrap_or(false) + } else { + false + } +} + +/// Normalize CI socket address to `host:port` for TCP clients. +pub fn ci_socket_tcp_addr(path: &str) -> String { + let path = path.trim(); + if let Some(rest) = path.strip_prefix("tcp:") { + rest.to_string() + } else { + path.to_string() + } +} fn default_scroll_pixels_per_line() -> f64 { 40.0 } fn default_lock_aspect_ratio() -> bool { true } @@ -435,6 +780,12 @@ impl Default for MachineConfig { mouse_scroll_pixels_per_line: default_scroll_pixels_per_line(), lock_aspect_ratio: default_lock_aspect_ratio(), scsi_deferred_int: default_scsi_deferred_int(), + audio: AudioConfig::default(), + machine: MachineSection::default(), + graphics: GraphicsSection::default(), + impact: ImpactSection::default(), + jit: JitConfig::default(), + perf: PerfConfig::default(), #[cfg(feature = "ultra64")] ultra64: Ultra64Config::default(), } @@ -477,6 +828,38 @@ impl MachineConfig { /// Validate bank sizes, returns a description of any errors. pub fn validate(&self) -> Result<(), String> { + if !self.machine.profile.supported() { + return Err(format!( + "machine profile \"{}\" is not implemented; use {}", + self.machine.profile.label(), + MachineProfile::IndyIp24.label(), + )); + } + if self.graphics.heads != 1 && self.graphics.heads != 2 { + return Err(format!( + "graphics.heads {} is invalid (valid: 1, 2)", + self.graphics.heads + )); + } + if self.graphics.board == GraphicsBoard::Xz { + if self.machine.profile != MachineProfile::IndyIp24 { + return Err( + "graphics.board \"xz\" is only valid on Indy (machine.profile = indy_ip24)".into(), + ); + } + if self.graphics.heads != 1 { + return Err("graphics.board \"xz\" does not support dual-head (graphics.heads must be 1)".into()); + } + if !self.graphics.resolution.is_guest() { + return Err("graphics.resolution presets require Newport (graphics.board = newport)".into()); + } + } + if self.impact.any_enabled() && self.machine.profile != MachineProfile::Indigo2Ip22 { + return Err( + "[impact] slots are preview-only on Indigo2 (machine.profile = indigo2_ip22)".into(), + ); + } + self.impact.validate()?; if self.scale < 1 || self.scale > 4 { return Err(format!("scale {} is invalid (valid: 1, 2, 3, 4)", self.scale)); } @@ -817,4 +1200,21 @@ mod export_tests { assert_eq!(back.scsi[&4].cdrom, true); println!("--- exported toml ---\n{s}"); } + + #[test] + fn indy_ip24_profile_validates_and_is_guinness() { + let mut cfg = MachineConfig::default(); + cfg.machine.profile = MachineProfile::IndyIp24; + cfg.validate().expect("indy_ip24 should validate"); + assert!(cfg.machine.profile.guinness()); + } + + #[test] + fn indigo2_profile_validates_and_is_fullhouse() { + let mut cfg = MachineConfig::default(); + cfg.machine.profile = MachineProfile::Indigo2Ip22; + cfg.validate().expect("indigo2_ip22 should validate on default build"); + assert!(cfg.machine.profile.supported()); + assert!(!cfg.machine.profile.guinness()); + } } diff --git a/src/disp.rs b/src/disp.rs index 2e95c6b..de56547 100644 --- a/src/disp.rs +++ b/src/disp.rs @@ -54,6 +54,16 @@ pub struct Rex3Screen { pub cursor_x_adjust: i32, /// Horizontal read offset into the framebuffer (XYWIN.x − 0x1000; typically 2) pub fb_x_offset: i32, + + /// When true, renderers may skip full compositor work and refresh only the + /// status bar (heartbeat frames with no FB/palette change). + pub status_bar_only: bool, + /// When true, `fb_rgb`/`fb_aux` snapshots were not copied — use live slices from refresh(). + pub fb_borrowed: bool, + pub dirty_y0: usize, + pub dirty_y1: usize, + pub dirty_x0: usize, + pub dirty_x1: usize, } impl Rex3Screen { @@ -76,6 +86,12 @@ impl Rex3Screen { topscan: 0, cursor_x_adjust: 0, fb_x_offset: 2, + status_bar_only: false, + fb_borrowed: false, + dirty_y0: 0, + dirty_y1: 0, + dirty_x0: 0, + dirty_x1: 0, } } @@ -145,125 +161,19 @@ impl Rex3Screen { // Returns (width, height, cursor_x_adjust). fn decode_video_timings(&self) -> (usize, usize, i32) { - let frame_ptr = self.vc2_regs[VC2_REG_VIDEO_ENTRY_PTR as usize] as usize; - let ram = &self.vc2_ram; - - let mut max_visible_width = 0usize; - let mut total_visible_lines = 0usize; - let mut hpos_to_visible: Option = None; - let mut curr_frame_ptr = frame_ptr; - let mut loop_safety = 0usize; - - loop { - if curr_frame_ptr + 1 >= ram.len() { break; } - let line_seq_ptr = ram[curr_frame_ptr] as usize; - let mut line_seq_len = ram[curr_frame_ptr + 1] as usize; - if line_seq_len == 0 { break; } - let mut curr_line_ptr = line_seq_ptr; - - while line_seq_len > 0 { - let mut line_visible_width = 0usize; - let mut eol = false; - let mut line_loop_safety = 0usize; - let mut state_c = 0u8; - let mut pixel_offset = 0usize; - let mut hpos_pixel: Option = None; - let mut visible_pixel: Option = None; - let mut hpos_seen_deasserted = false; - - while !eol { - if curr_line_ptr >= ram.len() { break; } - let w1 = ram[curr_line_ptr]; curr_line_ptr += 1; - let duration = ((w1 >> 8) & 0x7F) as usize; - let state_a = (w1 & 0x7F) as u8; - let sb_sc_absent = (w1 & 0x0080) != 0; - let mut eol_bit = (w1 & 0x8000) != 0; - - if !sb_sc_absent { - if curr_line_ptr >= ram.len() { break; } - let w2 = ram[curr_line_ptr]; - if (w2 & 0x8000) != 0 { eol_bit = true; } - curr_line_ptr += 1; - state_c = (w2 & 0x7F) as u8; - } - - eol = eol_bit; - let pixels = duration * 2; - - if hpos_pixel.is_none() { - if (state_a & VT_HPOS_VC_N) != 0 { - hpos_seen_deasserted = true; - } else if hpos_seen_deasserted { - hpos_pixel = Some(pixel_offset); - } - } - - let visible = (state_c & VT_CBLANK_XMAP_N) != 0 - && (state_a & VT_VIS_LN_VC_N) == 0 - && (state_a & VT_DSPLY_EN_RO_N) == 0; - - if visible { - if visible_pixel.is_none() { visible_pixel = Some(pixel_offset); } - line_visible_width += pixels; - } - pixel_offset += pixels; - line_loop_safety += 1; - if line_loop_safety > 1000 { break; } - } - - if line_visible_width > 0 { - total_visible_lines += 1; - if line_visible_width > max_visible_width { - max_visible_width = line_visible_width; - } - if hpos_to_visible.is_none() { - if let (Some(h), Some(v)) = (hpos_pixel, visible_pixel) { - if v >= h { hpos_to_visible = Some(v - h); } - } - } - } - - line_seq_len -= 1; - if curr_line_ptr >= ram.len() { break; } - curr_line_ptr = ram[curr_line_ptr] as usize; - } - - curr_frame_ptr += 2; - loop_safety += 1; - if loop_safety > 1000 { break; } - } - - if max_visible_width > 0 && total_visible_lines > 0 { - let w = max_visible_width.min(2048); - let h = total_visible_lines.min(1024); - let cursor_x_adjust = match hpos_to_visible { - Some(d) => { - let adj = d as i32 - 31; - if adj < 0 || adj > 64 { - println!("Rex3: WARNING: hpos_to_visible={} gives cursor_x_adjust={}, out of range, falling back to 11", d, adj); - 11 - } else { adj } - } - None => { - println!("Rex3: WARNING: HPOS leading edge not found in VT, falling back to cursor_x_adjust=11"); - 11 - } - }; - (w, h, cursor_x_adjust) - } else { - (0, 0, 0) - } + decode_vc2_timings(&self.vc2_regs, &self.vc2_ram) } /// Copy hardware device state into this cache, decode timings and DID. /// Returns `true` if the display resolution changed. /// - /// After this call, the caller should build a `CompositorSource` from the - /// fields of this struct and call `compositor.compose()`. + /// When `copy_fb` is false, skip the ~16 MB RGB/aux snapshot (palette/cursor + /// heartbeat refreshes that don't need new framebuffer pixels). pub fn refresh( &mut self, fb_rgb: &[u32], fb_aux: &[u32], + copy_fb: bool, vc2: &Mutex, xmap: &Mutex, cmap: &Mutex, @@ -273,10 +183,15 @@ impl Rex3Screen { let mut resized = false; // ── 1. Copy device state snapshots ────────────────────────────────────── - diag.fetch_or(Rex3::DIAG_LOOP_FB_COPY, Ordering::Relaxed); - self.fb_rgb.copy_from_slice(fb_rgb); - self.fb_aux.copy_from_slice(fb_aux); - diag.fetch_and(!Rex3::DIAG_LOOP_FB_COPY, Ordering::Relaxed); + if copy_fb { + diag.fetch_or(Rex3::DIAG_LOOP_FB_COPY, Ordering::Relaxed); + self.fb_rgb.copy_from_slice(fb_rgb); + self.fb_aux.copy_from_slice(fb_aux); + self.fb_borrowed = false; + diag.fetch_and(!Rex3::DIAG_LOOP_FB_COPY, Ordering::Relaxed); + } else { + self.fb_borrowed = true; + } diag.fetch_or(Rex3::DIAG_LOCK_VC2 | Rex3::DIAG_LOOP_VC2_COPY, Ordering::Relaxed); { @@ -343,13 +258,20 @@ impl Rex3Screen { resized } - /// Build a `CompositorSource` borrowing from this struct's caches. - /// Only valid after `refresh()` returns without early-exiting - /// (i.e. `width > 0 && height > 0`). - pub fn compositor_source(&self) -> CompositorSource<'_> { + pub fn compositor_source_from<'a>( + &'a self, + live_rgb: Option<&'a [u32]>, + live_aux: Option<&'a [u32]>, + ) -> CompositorSource<'a> { + let h = self.height.max(1); + let w = self.width.max(1); + let y0 = self.dirty_y0.min(h); + let y1 = self.dirty_y1.max(y0 + 1).min(h); + let x0 = self.dirty_x0.min(w); + let x1 = self.dirty_x1.max(x0 + 1).min(w); CompositorSource { - fb_rgb: &self.fb_rgb, - fb_aux: &self.fb_aux, + fb_rgb: if self.fb_borrowed { live_rgb.unwrap_or(&self.fb_rgb) } else { &self.fb_rgb }, + fb_aux: if self.fb_borrowed { live_aux.unwrap_or(&self.fb_aux) } else { &self.fb_aux }, did: &self.did, xmap_mode: &self.xmap_mode, xmap_config: self.xmap_config, @@ -364,9 +286,18 @@ impl Rex3Screen { fb_x_offset: self.fb_x_offset, width: self.width, height: self.height, + dirty_y0: y0, + dirty_y1: y1, + dirty_x0: x0, + dirty_x1: x1, + status_bar_only: self.status_bar_only, } } + pub fn compositor_source(&self) -> CompositorSource<'_> { + self.compositor_source_from(None, None) + } + /// Build an `OverlaySource` borrowing from this struct's caches. pub fn overlay_source(&self) -> OverlaySource<'_> { OverlaySource { @@ -387,6 +318,118 @@ impl Rex3Screen { } } +/// Decode visible display size from VC2 state (used by compositor and timing presets). +pub fn decode_vc2_timings(vc2_regs: &[u16; 32], vc2_ram: &[u16]) -> (usize, usize, i32) { + let frame_ptr = vc2_regs[VC2_REG_VIDEO_ENTRY_PTR as usize] as usize; + let ram = vc2_ram; + + let mut max_visible_width = 0usize; + let mut total_visible_lines = 0usize; + let mut hpos_to_visible: Option = None; + let mut curr_frame_ptr = frame_ptr; + let mut loop_safety = 0usize; + + loop { + if curr_frame_ptr + 1 >= ram.len() { break; } + let line_seq_ptr = ram[curr_frame_ptr] as usize; + let mut line_seq_len = ram[curr_frame_ptr + 1] as usize; + if line_seq_len == 0 { break; } + let mut curr_line_ptr = line_seq_ptr; + + while line_seq_len > 0 { + let mut line_visible_width = 0usize; + let mut eol = false; + let mut line_loop_safety = 0usize; + let mut state_c = 0u8; + let mut pixel_offset = 0usize; + let mut hpos_pixel: Option = None; + let mut visible_pixel: Option = None; + let mut hpos_seen_deasserted = false; + + while !eol { + if curr_line_ptr >= ram.len() { break; } + let w1 = ram[curr_line_ptr]; curr_line_ptr += 1; + let duration = ((w1 >> 8) & 0x7F) as usize; + let state_a = (w1 & 0x7F) as u8; + let sb_sc_absent = (w1 & 0x0080) != 0; + let mut eol_bit = (w1 & 0x8000) != 0; + + if !sb_sc_absent { + if curr_line_ptr >= ram.len() { break; } + let w2 = ram[curr_line_ptr]; + if (w2 & 0x8000) != 0 { eol_bit = true; } + curr_line_ptr += 1; + state_c = (w2 & 0x7F) as u8; + } + + eol = eol_bit; + let pixels = duration * 2; + + if hpos_pixel.is_none() { + if (state_a & VT_HPOS_VC_N) != 0 { + hpos_seen_deasserted = true; + } else if hpos_seen_deasserted { + hpos_pixel = Some(pixel_offset); + } + } + + let visible = (state_c & VT_CBLANK_XMAP_N) != 0 + && (state_a & VT_VIS_LN_VC_N) == 0 + && (state_a & VT_DSPLY_EN_RO_N) == 0; + + if visible { + if visible_pixel.is_none() { visible_pixel = Some(pixel_offset); } + line_visible_width += pixels; + } + pixel_offset += pixels; + line_loop_safety += 1; + if line_loop_safety > 1000 { break; } + } + + if line_visible_width > 0 { + total_visible_lines += 1; + if line_visible_width > max_visible_width { + max_visible_width = line_visible_width; + } + if hpos_to_visible.is_none() { + if let (Some(h), Some(v)) = (hpos_pixel, visible_pixel) { + if v >= h { hpos_to_visible = Some(v - h); } + } + } + } + + line_seq_len -= 1; + if curr_line_ptr >= ram.len() { break; } + curr_line_ptr = ram[curr_line_ptr] as usize; + } + + curr_frame_ptr += 2; + loop_safety += 1; + if loop_safety > 1000 { break; } + } + + if max_visible_width > 0 && total_visible_lines > 0 { + let w = max_visible_width.min(2048); + let h = total_visible_lines.min(1024); + let cursor_x_adjust = match hpos_to_visible { + Some(d) => { + let adj = d as i32 - 31; + if adj < 0 || adj > 64 { + println!("Rex3: WARNING: hpos_to_visible={} gives cursor_x_adjust={}, out of range, falling back to 11", d, adj); + 11 + } else { adj } + } + None => { + println!("Rex3: WARNING: HPOS leading edge not found in VT, falling back to cursor_x_adjust=11"); + 11 + } + }; + (w, h, cursor_x_adjust) + } else { + (0, 0, 0) + } +} + // ── Status bar ─────────────────────────────────────────────────────────────── /// Height of the status bar in pixels (one VGA glyph row = 16px) @@ -457,6 +500,8 @@ pub struct BarStats { pub now: std::time::Instant, pub cycles: u64, pub fasttick: u64, + /// REX3 refresh loop iteration count (use for status-bar Hz). + pub refresh_frames: u64, pub decoded_delta: u64, pub l1i_hits: u64, pub l1i_fetches: u64, @@ -485,10 +530,10 @@ pub struct StatusBar { led_red: bool, led_green: bool, prev_cycles: u64, - prev_fasttick: u64, + prev_refresh_frames: u64, prev_time: std::time::Instant, mips: f64, - fasthz: f64, + refresh_hz: f64, decode_pct: f64, l1i_hit_pct: f64, uncached_pct: f64, @@ -504,10 +549,10 @@ impl StatusBar { led_red: false, led_green: false, prev_cycles: 0, - prev_fasttick: 0, + prev_refresh_frames: 0, prev_time: std::time::Instant::now(), mips: 0.0, - fasthz: 0.0, + refresh_hz: 0.0, decode_pct: 0.0, l1i_hit_pct: 0.0, uncached_pct: 0.0, @@ -534,7 +579,7 @@ impl StatusBar { let dt = stats.now.duration_since(self.prev_time).as_secs_f64(); if dt >= 0.1 { let dc = stats.cycles.wrapping_sub(self.prev_cycles); - let df = stats.fasttick.wrapping_sub(self.prev_fasttick); + let dr = stats.refresh_frames.wrapping_sub(self.prev_refresh_frames); self.mips = (dc as f64 / dt / 1_000_000.0 * 10.0).round() / 10.0; #[cfg(feature = "developer")] { let total_fetches = stats.l1i_fetches + stats.uncached; @@ -542,9 +587,9 @@ impl StatusBar { self.l1i_hit_pct = if stats.l1i_fetches > 0 { stats.l1i_hits as f64 / stats.l1i_fetches as f64 * 100.0 } else { 0.0 }; self.uncached_pct = if dc > 0 { stats.uncached as f64 / dc as f64 * 100.0 } else { 0.0 }; } - self.fasthz = (df as f64 / dt).round(); + self.refresh_hz = (dr as f64 / dt).round(); self.prev_cycles = stats.cycles; - self.prev_fasttick = stats.fasttick; + self.prev_refresh_frames = stats.refresh_frames; self.prev_time = stats.now; } @@ -552,9 +597,9 @@ impl StatusBar { let rx_color = if self.enet_rx_fade > 0 { BAR_ACTIVE } else { BAR_DIM }; #[cfg(feature = "developer")] - let line = format!(" {:5.1} MIPS D:{:3.0}% I$:{:3.0}% UC:{:3.0}% {:4.0}Hz cs:{:08x} g{:04X} NET:", self.mips, self.decode_pct, self.l1i_hit_pct, self.uncached_pct, self.fasthz, stats.count_step as u32, stats.gfifo_pending); + let line = format!(" {:5.1} MIPS D:{:3.0}% I$:{:3.0}% UC:{:3.0}% {:4.0}Hz cs:{:08x} g{:04X} NET:", self.mips, self.decode_pct, self.l1i_hit_pct, self.uncached_pct, self.refresh_hz, stats.count_step as u32, stats.gfifo_pending); #[cfg(not(feature = "developer"))] - let line = format!(" {:5.1} MIPS {:4.0}Hz NET:", self.mips, self.fasthz); + let line = format!(" {:5.1} MIPS {:4.0}Hz NET:", self.mips, self.refresh_hz); let row_stride = 2048; for row in 0..STATUS_BAR_HEIGHT { diff --git a/src/gl_compositor.rs b/src/gl_compositor.rs index f5a012b..08350f3 100644 --- a/src/gl_compositor.rs +++ b/src/gl_compositor.rs @@ -192,6 +192,9 @@ pub struct GlCompositor { last_ramdac_hash: u64, last_xmap_hash: u64, last_cursor_hash: u64, + + /// Reusable PBO for async readback (iris-gui GL capture path). + readback_pbo: std::cell::RefCell>, } impl GlCompositor { @@ -212,6 +215,7 @@ impl GlCompositor { last_ramdac_hash: u64::MAX, last_xmap_hash: u64::MAX, last_cursor_hash: u64::MAX, + readback_pbo: std::cell::RefCell::new(None), } } @@ -331,6 +335,69 @@ impl GlCompositor { true } + fn upload_fb_u32_rows( + gl: &glow::Context, + tex: glow::Texture, + data: &[u32], + w: i32, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + ) { + let row_h = (y1 - y0) as i32; + let col_w = (x1 - x0) as i32; + if row_h <= 0 || col_w <= 0 { + return; + } + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(tex)); + gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, FB_W); + let start = y0 * FB_W as usize + x0; + let byte_len = if row_h <= 1 { + col_w as usize * 4 + } else { + (row_h as usize - 1) * FB_W as usize * 4 + col_w as usize * 4 + }; + let bytes = std::slice::from_raw_parts( + data[start..].as_ptr() as *const u8, + byte_len, + ); + gl.tex_sub_image_2d( + glow::TEXTURE_2D, 0, x0 as i32, y0 as i32, col_w, row_h, + glow::RED_INTEGER, glow::UNSIGNED_INT, + glow::PixelUnpackData::Slice(bytes), + ); + gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, 0); + } + } + + fn upload_fb_u8_rows( + gl: &glow::Context, + tex: glow::Texture, + data: &[u8], + w: i32, + y0: usize, + y1: usize, + ) { + let row_h = (y1 - y0) as i32; + if row_h <= 0 { + return; + } + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(tex)); + gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, FB_W); + let start = y0 * FB_W as usize; + let bytes = &data[start..start + (y1 - y0) * FB_W as usize]; + gl.tex_sub_image_2d( + glow::TEXTURE_2D, 0, 0, y0 as i32, w, row_h, + glow::RED_INTEGER, glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(bytes), + ); + gl.pixel_store_i32(glow::UNPACK_ROW_LENGTH, 0); + } + } + fn upload_fb_u32(gl: &glow::Context, tex: glow::Texture, data: &[u32], w: i32, h: i32) { unsafe { gl.bind_texture(glow::TEXTURE_2D, Some(tex)); @@ -439,10 +506,32 @@ impl Compositor for GlCompositor { let fbo = self.fbo.unwrap(); let vao = self.vao.unwrap(); - // ── Upload per-frame buffers ─────────────────────────────────────────── - Self::upload_fb_u32(gl, tex_rgb, src.fb_rgb, w, h); - Self::upload_fb_u32(gl, tex_aux, src.fb_aux, w, h); - Self::upload_fb_u8 (gl, tex_did, src.did, w, h); + // ── Upload per-frame buffers (partial when dirty region is a strict subset) ─ + let partial_y = src.dirty_y1 > src.dirty_y0 + && (src.dirty_y0 > 0 || src.dirty_y1 < src.height); + let partial_x = src.dirty_x1 > src.dirty_x0 + && (src.dirty_x0 > 0 || src.dirty_x1 < src.width); + let partial = partial_y || partial_x; + if partial { + Self::upload_fb_u32_rows( + gl, tex_rgb, src.fb_rgb, w, + src.dirty_x0, src.dirty_x1, src.dirty_y0, src.dirty_y1, + ); + Self::upload_fb_u32_rows( + gl, tex_aux, src.fb_aux, w, + src.dirty_x0, src.dirty_x1, src.dirty_y0, src.dirty_y1, + ); + } else { + Self::upload_fb_u32(gl, tex_rgb, src.fb_rgb, w, h); + Self::upload_fb_u32(gl, tex_aux, src.fb_aux, w, h); + } + if !src.status_bar_only { + if partial_y { + Self::upload_fb_u8_rows(gl, tex_did, src.did, w, src.dirty_y0, src.dirty_y1); + } else { + Self::upload_fb_u8(gl, tex_did, src.did, w, h); + } + } // ── Upload lookup tables (skip if unchanged) ─────────────────────────── let cmap_hash = hash_u32_slice(src.cmap); @@ -553,6 +642,9 @@ impl Compositor for GlCompositor { if let Some(f) = self.fbo.take() { gl.delete_framebuffer(f); } if let Some(p) = self.program.take() { gl.delete_program(p); } if let Some(v) = self.vao.take() { gl.delete_vertex_array(v); } + if let Some((pbo, _)) = self.readback_pbo.borrow_mut().take() { + gl.delete_buffer(pbo); + } } self.last_cmap_hash = u64::MAX; self.last_ramdac_hash = u64::MAX; @@ -567,12 +659,74 @@ impl Compositor for GlCompositor { fn readback_to_screen(&self, dst: &mut [u32], width: usize, height: usize, gl: &glow::Context) { let Some(fbo) = self.fbo else { return; }; let row_bytes = width * 4; - let mut tight: Vec = vec![0u8; row_bytes * height]; + let total_bytes = row_bytes * height; + let mut tight: Vec = vec![0u8; total_bytes]; unsafe { gl.bind_framebuffer(glow::READ_FRAMEBUFFER, Some(fbo)); - gl.read_pixels(0, 0, width as i32, height as i32, - glow::RGBA, glow::UNSIGNED_BYTE, - glow::PixelPackData::Slice(&mut tight)); + + let mut pbo_guard = self.readback_pbo.borrow_mut(); + let use_pbo = if let Some((pbo, cap)) = pbo_guard.as_mut() { + if *cap < total_bytes { + gl.delete_buffer(*pbo); + *pbo_guard = None; + false + } else { + gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(*pbo)); + gl.read_pixels( + 0, + 0, + width as i32, + height as i32, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelPackData::Slice(&mut []), + ); + gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(*pbo)); + let mapped = gl.map_buffer_range( + glow::PIXEL_PACK_BUFFER, + 0, + total_bytes as i32, + glow::MAP_READ_BIT, + ); + if !mapped.is_null() { + std::ptr::copy_nonoverlapping( + mapped, + tight.as_mut_ptr(), + total_bytes, + ); + let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER); + } + gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); + true + } + } else { + false + }; + + if !use_pbo { + gl.read_pixels( + 0, + 0, + width as i32, + height as i32, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelPackData::Slice(&mut tight), + ); + if pbo_guard.is_none() { + if let Ok(pbo) = gl.create_buffer() { + gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(pbo)); + gl.buffer_data_size( + glow::PIXEL_PACK_BUFFER, + total_bytes as i32, + glow::DYNAMIC_READ, + ); + gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); + *pbo_guard = Some((pbo, total_bytes)); + } + } + } + gl.bind_framebuffer(glow::READ_FRAMEBUFFER, None); } // FBO gl_FragCoord.y=0 = display row 0 (top); glReadPixels row 0 = that same bottom. diff --git a/src/hal2.rs b/src/hal2.rs index bbfda31..82222d5 100644 --- a/src/hal2.rs +++ b/src/hal2.rs @@ -1,6 +1,8 @@ use std::sync::Arc; use parking_lot::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread::{self, JoinHandle}; +use crate::config::AudioConfig; use crate::devlog::LogModule; use std::time::{Duration, Instant}; use std::io::Write; @@ -106,18 +108,34 @@ const MODE_STEREO: usize = 2; const MODE_QUAD: usize = 3; // Pre-buffer: accumulate this many ms of audio samples before pushing to the ring. -// This gives the CPU time to fill its circular DMA buffer before we start draining it, -// preventing initial underrun. -const PREBUF_MS: u64 = 20; -// Ring buffer capacity as a multiple of PREBUF_MS. Must absorb OS scheduling jitter. -// Expressed as a multiplier of PREBUF_MS stereo samples. +// Overridable via `[audio] prebuf_ms` in iris.toml (default 20). +// Ring buffer capacity as a multiple of prebuf_ms. Must absorb OS scheduling jitter. const RING_BUF_MULTIPLIER: usize = 16; // Consecutive dry reads before giving up prebuf and opening stream anyway. const DRY_LIMIT: u32 = 100; // Sample rates to try when opening the persistent output stream, in order. -const PREFERRED_RATES: &[u32] = &[48000, 44100, 22050]; +// Prefer 44100 to match IRIX BRES master and skip resampling on most hosts. +const PREFERRED_RATES: &[u32] = &[44100, 48000, 22050]; + +// Max stereo frames to drain per timer tick when catching up after scheduling jitter. +const MAX_FRAMES_PER_TICK: u32 = 8; + +#[cfg(windows)] +mod win_timer { + #[link(name = "winmm")] + extern "system" { + fn timeBeginPeriod(u_period: u32) -> u32; + fn timeEndPeriod(u_period: u32) -> u32; + } + pub fn begin_1ms() { + unsafe { timeBeginPeriod(1); } + } + pub fn end_1ms() { + unsafe { timeEndPeriod(1); } + } +} // ─── Audio output (owned by Codec A, opened once at start, closed at stop) ─── @@ -127,6 +145,7 @@ const PREFERRED_RATES: &[u32] = &[48000, 44100, 22050]; struct AudioOut { stream_rate: u32, producer: Producer, + underruns: Arc, // Keep stream alive; dropped when AudioOut is dropped at stop(). _stream: cpal::Stream, } @@ -157,7 +176,7 @@ impl Resampler { fn passthrough(&self) -> bool { self.in_rate == self.out_rate } - /// Push one input stereo pair. Returns 0, 1, or 2 output pairs via the closure. + /// Push one input stereo pair. fn push(&mut self, l: i16, r: i16, prod: &mut Producer) { self.last_l = l; self.last_r = r; @@ -190,13 +209,12 @@ struct CodecAState { prebuf: Vec, dry: u32, nonzero_seen: bool, - timer_id: Option, } impl CodecAState { fn new() -> Self { Self { out: None, resampler: None, prebuffering: true, prebuf: Vec::new(), - dry: 0, nonzero_seen: false, timer_id: None } + dry: 0, nonzero_seen: false } } fn reset_audio(&mut self) { // Keep `out` — stream stays open. Just reset codec-side state. @@ -216,12 +234,38 @@ impl CodecAState { } } } + + /// Flush fractional resampler state directly to the ring (end-of-stream tail). + fn flush_resampler_tail(&mut self) { + let (l, r) = match self.resampler.as_mut() { + Some(rs) if !rs.passthrough() && rs.acc > 0 => { + let pair = (rs.last_l, rs.last_r); + rs.acc = 0; + pair + } + _ => return, + }; + if let Some(o) = &mut self.out { + let _ = o.producer.push(l); + let _ = o.producer.push(r); + } + } } struct CodecBState { + out: Option, + resampler: Option, timer_id: Option, } +impl CodecBState { + fn push_to_ring(&mut self, l: i16, r: i16) { + if let (Some(rs), Some(o)) = (&mut self.resampler, &mut self.out) { + rs.push(l, r, &mut o.producer); + } + } +} + struct AesTxState { timer_id: Option, } @@ -279,40 +323,62 @@ pub struct Hal2 { state: Arc>, dma_clients: Vec>, timer_manager: Arc>>, - // Per-channel mutable state + audio_config: AudioConfig, + underruns: Arc, ca_state: Arc>, cb_state: Arc>, at_state: Arc>, ar_state: Arc>, + ca_pump_stop: Arc, + ca_pump_handle: Mutex>>, } // ─── cpal helpers ───────────────────────────────────────────────────────────── -fn prebuf_samples(rate: u32) -> usize { - (rate as usize * 2 * PREBUF_MS as usize) / 1000 +fn prebuf_samples(rate: u32, prebuf_ms: u64) -> usize { + (rate as usize * 2 * prebuf_ms as usize) / 1000 } /// Open a persistent stereo i16 cpal output stream, trying PREFERRED_RATES in order. /// The stream plays silence when the ring buffer is empty. -fn open_persistent_output() -> Option { +fn open_persistent_output(cfg: &AudioConfig, underruns: Arc) -> Option { let host = cpal::default_host(); let device = host.default_output_device()?; for &rate in PREFERRED_RATES { + let buffer_size = cfg.cpal_buffer_frames + .map(|n| cpal::BufferSize::Fixed(n)) + .unwrap_or(cpal::BufferSize::Default); let config = cpal::StreamConfig { channels: 2, sample_rate: cpal::SampleRate(rate), - buffer_size: cpal::BufferSize::Default, + buffer_size, }; - let ring_size = prebuf_samples(rate) * RING_BUF_MULTIPLIER; + let ring_size = prebuf_samples(rate, cfg.prebuf_ms) * RING_BUF_MULTIPLIER; let err_fn = |err: cpal::StreamError| { eprintln!("HAL2: cpal stream error: {:?}", err); }; + let underruns_cb = underruns.clone(); // Try f32 first (macOS CoreAudio native), then i16 (Linux ALSA). let (producer, stream) = { let (p, mut c) = RingBuffer::::new(ring_size); let data_fn = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut last_l: i16 = 0; + let mut last_r: i16 = 0; + let mut next_left = true; for sample in data.iter_mut() { - *sample = c.pop().unwrap_or(0) as f32 / 32768.0; + match c.pop() { + Ok(v) => { + if next_left { last_l = v; } else { last_r = v; } + next_left = !next_left; + *sample = v as f32 / 32768.0; + } + Err(_) => { + underruns_cb.fetch_add(1, Ordering::Relaxed); + let v = if next_left { last_l } else { last_r }; + next_left = !next_left; + *sample = v as f32 / 32768.0; + } + } } }; match device.build_output_stream(&config, data_fn, err_fn.clone(), None) { @@ -320,9 +386,24 @@ fn open_persistent_output() -> Option { Err(_) => { // f32 failed, try i16 let (p, mut c) = RingBuffer::::new(ring_size); + let underruns_cb = underruns.clone(); let data_fn = move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + let mut last_l: i16 = 0; + let mut last_r: i16 = 0; + let mut next_left = true; for sample in data.iter_mut() { - *sample = c.pop().unwrap_or(0); + match c.pop() { + Ok(v) => { + if next_left { last_l = v; } else { last_r = v; } + next_left = !next_left; + *sample = v; + } + Err(_) => { + underruns_cb.fetch_add(1, Ordering::Relaxed); + *sample = if next_left { last_l } else { last_r }; + next_left = !next_left; + } + } } }; match device.build_output_stream(&config, data_fn, err_fn.clone(), None) { @@ -341,6 +422,7 @@ fn open_persistent_output() -> Option { return Some(AudioOut { stream_rate: rate, producer, + underruns, _stream: stream, }); } @@ -353,7 +435,7 @@ fn open_persistent_output() -> Option { // ─── impl Hal2 ──────────────────────────────────────────────────────────────── impl Hal2 { - pub fn new(dma_clients: Vec>) -> Self { + pub fn new(dma_clients: Vec>, audio_config: AudioConfig) -> Self { Self { state: Arc::new(Mutex::new(Hal2State { isr: 0, @@ -374,10 +456,14 @@ impl Hal2 { })), dma_clients, timer_manager: Arc::new(std::sync::OnceLock::new()), + audio_config, + underruns: Arc::new(AtomicU64::new(0)), ca_state: Arc::new(Mutex::new(CodecAState::new())), - cb_state: Arc::new(Mutex::new(CodecBState { timer_id: None })), + cb_state: Arc::new(Mutex::new(CodecBState { out: None, resampler: None, timer_id: None })), at_state: Arc::new(Mutex::new(AesTxState { timer_id: None })), ar_state: Arc::new(Mutex::new(AesRxState { loopback: std::collections::VecDeque::new(), timer_id: None })), + ca_pump_stop: Arc::new(AtomicBool::new(true)), + ca_pump_handle: Mutex::new(None), } } @@ -411,97 +497,120 @@ impl Hal2 { self.ar_state.lock().loopback.clear(); } - // ── Codec A output timer ────────────────────────────────────────────────── - - fn arm_codeca(&self) { - let Some(tm) = self.timer_manager.get() else { return; }; - self.disarm_codeca(); - - let (dma_ch, mode, rate, pitch_rate) = { - let s = self.state.lock(); - let (ch, clk, mode) = s.codeca_cfg(); - let rate = s.bres_rate(clk); - let (_, cb_clk, _) = s.codecb_cfg(); - let cb_rate = s.bres_rate(cb_clk); - let pitch_rate = if cb_rate > 0 { cb_rate } else { 44100 }; - (ch, mode, rate, pitch_rate) - }; - - if rate == 0 || dma_ch >= self.dma_clients.len() { return; } - - let period = Duration::from_secs_f64(1.0 / pitch_rate as f64); - let dma_client = self.dma_clients[dma_ch].clone(); - let ca_state = self.ca_state.clone(); - - let id = tm.add_recurring(Instant::now() + period, period, (), move |_| { - let mut st = ca_state.lock(); - - // No audio output — still drain DMA so the kernel doesn't hang - // waiting for PDMA_CTRL_ACT to clear. - let stream_rate = match st.out.as_ref() { - Some(o) => o.stream_rate, - None => { - let _ = read_frame_from(&dma_client, mode); - return TimerReturn::Continue; - } - }; - - // (Re)build resampler if codec rate changed. - // Use codec B rate as the declared input rate (experiment). - if st.resampler.as_ref().map_or(true, |r| r.in_rate != pitch_rate) { - st.resampler = Some(Resampler::new(pitch_rate, stream_rate)); - dlog_dev!(LogModule::Hal2, "HAL2: Codec A resampler {}Hz (pitch={}) → {}Hz", rate, pitch_rate, stream_rate); + // ── Codec A output (HAL2-Pump thread — spin-wait, no thread::sleep) ───── + + fn codeca_dma_tick( + ca_state: &Arc>, + dma_client: &Arc, + mode: usize, + rate: u32, + pitch_rate: u32, + prebuf_ms: u64, + ) { + let mut st = ca_state.lock(); + + let stream_rate = match st.out.as_ref() { + Some(o) => o.stream_rate, + None => { + let _ = read_frame_from(dma_client, mode); + return; } + }; - let frame = read_frame_from(&dma_client, mode); + if st.resampler.as_ref().map_or(true, |r| r.in_rate != pitch_rate) { + st.resampler = Some(Resampler::new(pitch_rate, stream_rate)); + } - match frame { + let prebuf_target = prebuf_samples(rate, prebuf_ms); + for _ in 0..MAX_FRAMES_PER_TICK { + match read_frame_from(dma_client, mode) { Some((l, r)) => { st.dry = 0; if !st.nonzero_seen && (l != 0 || r != 0) { - dlog_dev!(LogModule::Hal2, "HAL2: Codec A first non-zero: l={} r={}", l, r); st.nonzero_seen = true; } if st.prebuffering { - // Accumulate before feeding the ring to prevent underrun. st.prebuf.push(l); st.prebuf.push(r); - if st.prebuf.len() >= prebuf_samples(rate) { + if st.prebuf.len() >= prebuf_target { let samples = std::mem::take(&mut st.prebuf); st.push_to_ring(&samples); - dlog_dev!(LogModule::Hal2, "HAL2: Codec A prebuf flushed ({} frames)", samples.len() / 2); st.prebuffering = false; } } else { - // Active: push directly. st.push_to_ring(&[l, r]); } } None => { st.dry += 1; + if st.nonzero_seen && st.dry == 1 { + st.flush_resampler_tail(); + } if st.prebuffering && !st.prebuf.is_empty() && st.dry >= DRY_LIMIT { - // Flush whatever we buffered so far rather than waiting forever. let samples = std::mem::take(&mut st.prebuf); st.push_to_ring(&samples); - dlog_dev!(LogModule::Hal2, "HAL2: Codec A prebuf flushed (dry) after {} dry reads", st.dry); st.prebuffering = false; st.dry = 0; } + break; } } + } + } - TimerReturn::Continue - }); + fn arm_codeca(&self) { + self.disarm_codeca(); + + let (dma_ch, mode, rate, pitch_rate) = { + let s = self.state.lock(); + let (ch, clk, mode) = s.codeca_cfg(); + let rate = s.bres_rate(clk); + let (_, cb_clk, _) = s.codecb_cfg(); + let cb_rate = s.bres_rate(cb_clk); + let pitch_rate = if cb_rate > 0 { cb_rate } else { 44100 }; + (ch, mode, rate, pitch_rate) + }; + + if rate == 0 || dma_ch >= self.dma_clients.len() { return; } + + let period = Duration::from_secs_f64(1.0 / pitch_rate as f64); + let dma_client = self.dma_clients[dma_ch].clone(); + let ca_state = self.ca_state.clone(); + let prebuf_ms = self.audio_config.prebuf_ms; + let stop = self.ca_pump_stop.clone(); + stop.store(false, Ordering::Relaxed); + + let handle = thread::Builder::new() + .name("HAL2-Pump".into()) + .spawn(move || { + let mut next = Instant::now(); + while !stop.load(Ordering::Relaxed) { + let now = Instant::now(); + if now >= next { + Self::codeca_dma_tick(&ca_state, &dma_client, mode, rate, pitch_rate, prebuf_ms); + next += period; + if next < now { + next = now + period; + } + } else { + // Match hptimer: spin for sub-200µs waits (44.1 kHz ≈ 23µs). + std::hint::spin_loop(); + } + } + }) + .ok(); - self.ca_state.lock().timer_id = Some(id); + if let Some(h) = handle { + *self.ca_pump_handle.lock() = Some(h); + } } fn disarm_codeca(&self) { - let id = self.ca_state.lock().timer_id.take(); - if let Some(id) = id { - if let Some(tm) = self.timer_manager.get() { tm.remove(id); } + self.ca_pump_stop.store(true, Ordering::Relaxed); + if let Some(h) = self.ca_pump_handle.lock().take() { + let _ = h.join(); } - self.ca_state.lock().reset_audio(); // keeps `out` (stream stays open) + self.ca_state.lock().reset_audio(); } // ── Codec B input (silence writer) timer ───────────────────────────────── @@ -510,26 +619,44 @@ impl Hal2 { let Some(tm) = self.timer_manager.get() else { return; }; self.disarm_codecb(); - let (dma_ch, mode, rate) = { + let (dma_ch, mode, rate, quad_dual) = { let s = self.state.lock(); let (ch, clk, mode) = s.codecb_cfg(); let rate = s.bres_rate(clk); - (ch, mode, rate) + let quad_dual = s.isr & isr::CODEC_MODE as u16 != 0; + (ch, mode, rate, quad_dual) }; if rate == 0 || dma_ch >= self.dma_clients.len() { return; } let period = Duration::from_secs_f64(1.0 / rate as f64); let dma_client = self.dma_clients[dma_ch].clone(); + let cb_state = self.cb_state.clone(); + let audio_cfg = self.audio_config.clone(); + let underruns = self.underruns.clone(); let id = tm.add_recurring(Instant::now() + period, period, (), move |_| { - let _ = dma_client.write(0, false); - if mode == MODE_STEREO || mode == MODE_QUAD { - let _ = dma_client.write(0, false); - } - if mode == MODE_QUAD { - let _ = dma_client.write(0, false); + if quad_dual { + let mut st = cb_state.lock(); + if st.out.is_none() { + st.out = open_persistent_output(&audio_cfg, underruns.clone()); + } + if let Some((l, r)) = read_frame_from(&dma_client, mode) { + let stream_rate = st.out.as_ref().map(|o| o.stream_rate).unwrap_or(rate); + if st.resampler.as_ref().map_or(true, |r| r.in_rate != rate) { + st.resampler = Some(Resampler::new(rate, stream_rate)); + } + st.push_to_ring(l, r); + } + } else { let _ = dma_client.write(0, false); + if mode == MODE_STEREO || mode == MODE_QUAD { + let _ = dma_client.write(0, false); + } + if mode == MODE_QUAD { + let _ = dma_client.write(0, false); + let _ = dma_client.write(0, false); + } } TimerReturn::Continue }); @@ -542,6 +669,8 @@ impl Hal2 { if let Some(id) = id { if let Some(tm) = self.timer_manager.get() { tm.remove(id); } } + let mut st = self.cb_state.lock(); + st.resampler = None; } // ── AES TX drain timer (no cpal output) ────────────────────────────────── @@ -947,6 +1076,10 @@ impl Hal2 { BUS_OK } + pub fn underrun_count(&self) -> u64 { + self.underruns.load(Ordering::Relaxed) + } + pub fn register_locks(self: &Arc) { use crate::locks::register_lock_fn; let me = self.clone(); register_lock_fn("hal2::state", move || me.state.is_locked()); @@ -985,7 +1118,7 @@ fn read_frame_from(client: &Arc, mode: usize) -> Option<(i16, i16 impl Default for Hal2 { fn default() -> Self { - Self::new(Vec::new()) + Self::new(Vec::new(), AudioConfig::default()) } } @@ -993,8 +1126,10 @@ impl Device for Hal2 { fn step(&self, _cycles: u64) {} fn start(&self) { - // Open persistent audio output once. Codec A timer will push into it. - let audio = open_persistent_output(); + #[cfg(windows)] + win_timer::begin_1ms(); + let underruns = self.underruns.clone(); + let audio = open_persistent_output(&self.audio_config, underruns); if audio.is_none() { eprintln!("HAL2: no audio output available"); } @@ -1007,8 +1142,11 @@ impl Device for Hal2 { fn stop(&self) { self.disarm_all(); - // Drop the audio output stream. + // Drop the audio output streams. self.ca_state.lock().out = None; + self.cb_state.lock().out = None; + #[cfg(windows)] + win_timer::end_1ms(); } fn is_running(&self) -> bool { @@ -1073,14 +1211,22 @@ impl Device for Hal2 { drop(s); let ca = self.ca_state.lock(); - writeln!(writer, "Codec A out: {} pitch: {} prebuf: {} prebuffering: {} timer: {}", + writeln!(writer, "Codec A out: {} pitch: {} prebuf: {} prebuffering: {}", ca.out.as_ref().map_or("none".to_string(), |o| format!("{}Hz", o.stream_rate)), ca.resampler.as_ref().map_or("none".to_string(), |r| format!("{}Hz", r.in_rate)), ca.prebuf.len() / 2, ca.prebuffering, - ca.timer_id.map_or("none".to_string(), |id| format!("{:#x}", id)), + ).unwrap(); + writeln!(writer, "Codec A pump: {} prebuf_ms={}", + if self.ca_pump_handle.lock().is_some() { "HAL2-Pump active" } else { "stopped" }, + self.audio_config.prebuf_ms, ).unwrap(); drop(ca); + writeln!(writer, "cpal underruns (samples): {} prebuf_ms={} cpal_buffer={}", + self.underruns.load(Ordering::Relaxed), + self.audio_config.prebuf_ms, + self.audio_config.cpal_buffer_frames.map_or("default".to_string(), |n| n.to_string()), + ).unwrap(); writeln!(writer, "Codec B timer: {}", self.cb_state.lock().timer_id.map_or("none".to_string(), |id| format!("{:#x}", id))).unwrap(); diff --git a/src/headless_gl.rs b/src/headless_gl.rs new file mode 100644 index 0000000..31ea4c9 --- /dev/null +++ b/src/headless_gl.rs @@ -0,0 +1,91 @@ +//! Minimal hidden-window GL context for off-thread compositor capture (iris-gui). +//! +//! OpenGL calls run on the thread that creates this context (REX3-Refresh). + +use std::ffi::CString; + +use glow::HasContext; +use glutin::config::{ConfigTemplateBuilder, GlConfig}; +use glutin::context::{ContextAttributesBuilder, PossiblyCurrentContext}; +use glutin::display::GetGlDisplay; +use glutin::prelude::*; +use glutin::surface::{GlSurface, Surface, SwapInterval, WindowSurface}; +use glutin_winit::{DisplayBuilder, GlWindow}; +use raw_window_handle::HasRawWindowHandle; +use winit::event_loop::EventLoop; +use winit::window::WindowBuilder; + +/// Hidden 1×1 window + GL context for offscreen GlCompositor use. +pub struct HeadlessGl { + pub gl: glow::Context, + _event_loop: EventLoop<()>, + _window: winit::window::Window, + _context: PossiblyCurrentContext, + _surface: Surface, +} + +impl HeadlessGl { + /// Create a hidden GL context. Returns None if the platform cannot init GL. + pub fn new() -> Option { + let event_loop = EventLoop::new().ok()?; + let window_builder = WindowBuilder::new() + .with_title("iris-headless-gl") + .with_visible(false) + .with_inner_size(winit::dpi::LogicalSize::new(1u32, 1u32)); + + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true); + + let display_builder = DisplayBuilder::new().with_window_builder(Some(window_builder)); + let (window, gl_config) = display_builder + .build(&event_loop, template, |configs| { + configs.reduce(|accum, config| { + if config.num_samples() > accum.num_samples() { config } else { accum } + }).unwrap() + }) + .ok()?; + + let window = window?; + let raw_window_handle = window.raw_window_handle(); + let gl_display = gl_config.display(); + + let context_attributes = ContextAttributesBuilder::new().build(Some(raw_window_handle)); + let not_current_gl_context = unsafe { + gl_display.create_context(&gl_config, &context_attributes).ok()? + }; + + let attrs = window.build_surface_attributes(Default::default()); + let gl_surface = unsafe { + gl_display.create_window_surface(&gl_config, &attrs).ok()? + }; + + let gl_context = not_current_gl_context.make_current(&gl_surface).ok()?; + let _ = gl_surface.set_swap_interval(&gl_context, SwapInterval::DontWait); + + let gl = unsafe { + glow::Context::from_loader_function(|s| { + gl_display.get_proc_address(&CString::new(s).unwrap()) + }) + }; + + Some(Self { + gl, + _event_loop: event_loop, + _window: window, + _context: gl_context, + _surface: gl_surface, + }) + } +} + +impl Drop for HeadlessGl { + fn drop(&mut self) { + unsafe { + self.gl.finish(); + } + } +} + +// GL context is owned and used only on the creating thread (REX3-Refresh). +unsafe impl Send for HeadlessGl {} diff --git a/src/hpc3.rs b/src/hpc3.rs index 4663ff4..5040fc7 100644 --- a/src/hpc3.rs +++ b/src/hpc3.rs @@ -6,7 +6,7 @@ use std::io::Write as IoWrite; use crate::devlog::{LogModule, devlog_mask}; use crate::traits::{BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, BUS_ERR, BusDevice, Device, DmaClient, DmaStatus, Resettable, Saveable}; use crate::snapshot::{get_field, u32_slice_to_toml, load_u32_slice, toml_u32, toml_bool, hex_u32}; -use crate::config::NetworkConfig; +use crate::config::{AudioConfig, NetworkConfig}; use crate::eeprom_93c56::Eeprom93c56; use crate::ioc::Ioc; use crate::ds1x86::Ds1x86; @@ -1008,19 +1008,22 @@ pub struct Hpc3 { scsi_dev: Arc, hal2: Option>, pdma_dump: Arc, + /// Indy (Guinness) vs Indigo2 (fullhouse). No HPC3 register divergence from + /// Indy today — retained for future fullhouse paths (EISA pbus, dual INT2). + #[allow(dead_code)] guinness: bool, } impl Hpc3 { pub fn new(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, cpu_cycles: Arc) -> Self { - Self::with_net(eeprom, ioc, guinness, heartbeat, NetworkConfig::default(), false, "nvram.bin".to_string(), cpu_cycles, true) + Self::with_net(eeprom, ioc, guinness, heartbeat, NetworkConfig::default(), false, AudioConfig::default(), "nvram.bin".to_string(), cpu_cycles, true) } /// `no_audio` skips HAL2 audio init (used by `--noaudio` and also by full /// `--headless`, which can't run audio in CI). /// `nvram_path` is the on-disk NVRAM file (loaded at startup, default save /// target for `iris-ci rtc-save`). - pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, no_audio: bool, nvram_path: String, cpu_cycles: Arc, scsi_deferred_int: bool) -> Self { + pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, no_audio: bool, audio: AudioConfig, nvram_path: String, cpu_cycles: Arc, scsi_deferred_int: bool) -> Self { let nfs = net.nfs; let port_forwards = net.port_forward; let subnet = net.nat_subnet.unwrap_or_default(); @@ -1114,7 +1117,7 @@ impl Hpc3 { let scsi_dev = Arc::new(Wd33c93a::new_with_config(Some(scsi0_dma), Some(scsi0_irq), heartbeat.clone(), cpu_cycles, scsi_deferred_int)); let _ = scsi_wd_lock.set(scsi_dev.clone()); - let hal2 = if no_audio { None } else { Some(Arc::new(Hal2::new(dma_clients[0..8].to_vec()))) }; + let hal2 = if no_audio { None } else { Some(Arc::new(Hal2::new(dma_clients[0..8].to_vec(), audio))) }; Self { state, diff --git a/src/hptimer.rs b/src/hptimer.rs index e25ab92..a58a812 100644 --- a/src/hptimer.rs +++ b/src/hptimer.rs @@ -310,28 +310,44 @@ fn timer_thread_loop(inner: Arc>, new_timer_added: Arc< } } - // 4. Run the fetched callback + // 4. Run the fetched callback (with late catch-up for recurring timers). + const MAX_CATCHUP: u32 = 8; if let Some((idx, generation, cb_opt, _og_period, _og_expiration)) = to_call { if let Some(mut cb) = cb_opt { - let result = cb.call(); + let mut result = cb.call(); + let mut catch_up = 0u32; - // Lock again to apply the result. - let mut guard = inner.lock(); - if guard.stop { - break; - } + loop { + let mut guard = inner.lock(); + if guard.stop { + break; + } - if let Some(slot) = guard.slots.get_mut(idx) { - if slot.generation == generation && slot.timer.is_some() { + if let Some(slot) = guard.slots.get_mut(idx) { + if slot.generation != generation || slot.timer.is_none() { + break; + } let t = slot.timer.as_mut().unwrap(); t.callback = Some(cb); + let period = t.period; + let enabled = t.enabled; match result { TimerReturn::Continue => { - if let Some(period) = t.period { + if let Some(period) = period { t.next_expiration += period; + let now = Instant::now(); + if catch_up + 1 < MAX_CATCHUP + && enabled + && t.next_expiration <= now + { + catch_up += 1; + cb = t.callback.take().unwrap(); + drop(guard); + result = cb.call(); + continue; + } } else { - // One shot dies on Continue guard.deallocate(((generation as u64) << 32) | (idx as u64)); } } @@ -347,10 +363,11 @@ fn timer_thread_loop(inner: Arc>, new_timer_added: Arc< } TimerReturn::RescheduleRecurring(new_period) => { t.period = Some(new_period); - t.next_expiration += new_period; // Advance by the new period + t.next_expiration += new_period; } } } + break; } } continue; // Re-evaluate time continuously @@ -366,14 +383,21 @@ fn timer_thread_loop(inner: Arc>, new_timer_added: Arc< let delay = target - sleep_now; - if delay > Duration::from_micros(200) { - // Park with a safe threshold + if delay > Duration::from_micros(500) { + // Park with a safe threshold for long waits. let park_duration = delay - Duration::from_micros(100); thread::park_timeout(park_duration); + } else if delay > Duration::from_micros(200) { + // Medium wait: short park then spin the remainder. + thread::park_timeout(delay - Duration::from_micros(50)); } else { - // Short sleep instead of spin — yields the core without - // burning CPU while waiting for the timer to fire. - thread::sleep(Duration::from_micros(50)); + // Audio-scale waits (~23 µs at 44.1 kHz): spin to avoid 50 µs sleep floor. + let deadline = Instant::now() + delay; + while Instant::now() < deadline + && !new_timer_added.load(Ordering::Acquire) + { + std::hint::spin_loop(); + } } } } else { diff --git a/src/idle_park.rs b/src/idle_park.rs new file mode 100644 index 0000000..e06d3f9 --- /dev/null +++ b/src/idle_park.rs @@ -0,0 +1,112 @@ +//! Kernel idle-loop detection and in-place CPU thread parking. +//! +//! Shared by the interpreter run loop (`mips_exec.rs`) and the JIT dispatch +//! loop (`jit/dispatch.rs`). See `rules/perf/idle-pause-work.md`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use crate::mips_core::{CAUSE_IP_MASK, CAUSE_IP7, STATUS_IM_MASK}; +use crate::mips_core::MipsCore; + +const IDLE_RING: usize = 32; +const SLICE_NS: u64 = 1_000_000; +const MIN_SLICE_NS: u64 = 50_000; + +/// Tracks recent architectural-state hashes to detect polling idle loops. +#[derive(Default)] +pub struct IdleParkState { + ring: [u64; IDLE_RING], + ring_len: usize, + ring_pos: usize, +} + +impl IdleParkState { + /// Hash PC + GPRs (excluding k0/k1 scratch registers). + fn hash_state(core: &MipsCore) -> u64 { + let mut h = core.pc; + for (i, &g) in core.gpr.iter().enumerate() { + if i == 26 || i == 27 { + continue; + } + h = h.rotate_left(7) ^ g; + } + h + } + + /// Update idle ring. Returns true when the current state repeated (safe to park). + pub fn update(&mut self, core: &MipsCore) -> bool { + let ie = core.interrupts_enabled(); + let pending = core.interrupts.load(Ordering::Relaxed) as u32; + let ip = (core.cp0_cause | pending) & CAUSE_IP_MASK; + let im = core.cp0_status & STATUS_IM_MASK; + let interrupt_ready = (ip & im) != 0; + + if !(ie && !interrupt_ready) { + self.ring_len = 0; + self.ring_pos = 0; + return false; + } + + let h = Self::hash_state(core); + if self.ring[..self.ring_len].contains(&h) { + return true; + } + self.ring[self.ring_pos] = h; + self.ring_pos = (self.ring_pos + 1) % IDLE_RING; + if self.ring_len < IDLE_RING { + self.ring_len += 1; + } + false + } + + /// Park in ≤1 ms slices until an interrupt is due or pending. + pub fn park(&self, core: &mut MipsCore, running: &AtomicBool) { + let slow_hw = core.compare_delta_slow >> 32; + let cs = core.count_step; + if slow_hw == 0 || cs == 0 { + return; + } + + loop { + if !running.load(Ordering::Relaxed) { + break; + } + let cnt = core.cp0_count; + let cmp = core.cp0_compare; + let diff = cmp.wrapping_sub(cnt); + if (diff >> 63) != 0 || (diff >> 32) == 0 { + core.cp0_count = cmp; + core.cp0_cause |= CAUSE_IP7; + break; + } + let pending = core.interrupts.load(Ordering::Relaxed) as u32; + let ip = (core.cp0_cause | pending) & CAUSE_IP_MASK; + let im = core.cp0_status & STATUS_IM_MASK; + if (ip & im) != 0 { + break; + } + + let rem_hw = diff >> 32; + let ns_to_tick = (rem_hw as u128 * 10_000_000u128 / slow_hw as u128) as u64; + let slice_ns = ns_to_tick.min(SLICE_NS); + if slice_ns < MIN_SLICE_NS { + core.cp0_count = cmp; + core.cp0_cause |= CAUSE_IP7; + break; + } + + let t0 = Instant::now(); + std::thread::sleep(Duration::from_nanos(slice_ns)); + let elapsed_ns = t0.elapsed().as_nanos() as u64; + let adv_hw = (elapsed_ns as u128 * slow_hw as u128 / 10_000_000u128) as u64; + core.cp0_count = core.cp0_count.wrapping_add(adv_hw << 32); + let adv_instrs = (((adv_hw as u128) << 32) / cs as u128) as u64; + core.local_cycles = core.local_cycles.wrapping_add(adv_instrs); + } + } +} + +pub fn idle_park_enabled() -> bool { + std::env::var_os("IRIS_NO_IDLE").is_none() +} diff --git a/src/ioc.rs b/src/ioc.rs index 36fc1fd..280a1a0 100644 --- a/src/ioc.rs +++ b/src/ioc.rs @@ -88,6 +88,23 @@ pub mod map_regs { // dispatched via lcl2_intr → fires as L0/IP2 (VECTOR_GIOEXP0 = 22 = lcl_id 2, level 6). pub const GIO_EXP0: u8 = 1 << 6; pub const GIO_EXP1: u8 = 1 << 7; + // IP22 fullhouse: same MAP bits 6–7 are GFX FIFO drain feedback (not expansion). + pub const GFX_DRAIN0: u8 = 1 << 6; + pub const GFX_DRAIN1: u8 = 1 << 7; +} + +/// IP22 fullhouse EXTIO register (Linux `sgioc->extio`). Shared GIO interrupt +/// lines from the graphics slot (SG) and expansion slot 0 (S0) are latched here; +/// `gc_select` bit 0 chooses which slot feeds L0/L1 FIFO/GFX/retrace bits. +pub mod extio_regs { + pub const SG_RETRACE: u32 = 1 << 8; + pub const SG_IRQ_1: u32 = 1 << 9; // gfx.int → L0 GRAPHICS + pub const SG_IRQ_2: u32 = 1 << 10; // gfx.fifofull → L0 FIFO_FULL + pub const S0_RETRACE: u32 = 1 << 12; + pub const S0_IRQ_1: u32 = 1 << 13; + pub const S0_IRQ_2: u32 = 1 << 14; + pub const SG_MASK: u32 = SG_RETRACE | SG_IRQ_1 | SG_IRQ_2; + pub const S0_MASK: u32 = S0_RETRACE | S0_IRQ_1 | S0_IRQ_2; } #[derive(Debug, Clone, Copy, PartialEq)] @@ -115,6 +132,10 @@ pub enum IocInterrupt { KbMouse, GioExp0, // LIO_GIO_EXP0 = bit 6 — GIO expansion slot 0 (Indy IP24) GioExp1, // LIO_GIO_EXP1 = bit 7 — GIO expansion slot 1 + /// IP22 fullhouse: vertical retrace from expansion slot 0 (extio S0_RETRACE). + GioExp0Retrace, + GfxDrain0, // LIO_DRAIN0 = bit 6 — GFX FIFO drain (Indigo2 fullhouse) + GfxDrain1, // LIO_DRAIN1 = bit 7 — second head drain (Indigo2 fullhouse) Mappable0, // Timer 0 Mappable1, // Timer 1 Mappable2, @@ -137,6 +158,10 @@ struct IocState { // Misc Registers gc_select: u8, + /// Fullhouse EXTIO shadow — shared GIO IRQ latch before gc_select mux. + extio: u32, + /// IP22 fullhouse (Indigo2) — enables EXTIO/gc_select IRQ mux. + fullhouse: bool, gen_cntl: u8, panel: u8, read_reg: u8, @@ -228,6 +253,8 @@ impl Ioc { map_pol: 0, err_stat: 0, gc_select: 0, + extio: 0, + fullhouse: !guinness, gen_cntl: 0, panel: 1, // Power State (Bit 0) = 1 (On) read_reg: 0x70, // Ethernet/SCSI Power Good (Bits 6,5,4 = 1) @@ -302,16 +329,34 @@ impl Ioc { let mut state = self.state.lock(); match source { // Local 0 - IocInterrupt::Graphics => if active { state.l0_stat |= l0_regs::GRAPHICS } else { state.l0_stat &= !l0_regs::GRAPHICS }, + IocInterrupt::Graphics => { + if self.guinness { + if active { state.l0_stat |= l0_regs::GRAPHICS } else { state.l0_stat &= !l0_regs::GRAPHICS } + } else { + state.set_extio_sg(extio_regs::SG_IRQ_1, active); + } + } IocInterrupt::Parallel => if active { state.l0_stat |= l0_regs::PARALLEL } else { state.l0_stat &= !l0_regs::PARALLEL }, IocInterrupt::McDma => if active { state.l0_stat |= l0_regs::MC_DMA } else { state.l0_stat &= !l0_regs::MC_DMA }, IocInterrupt::Ethernet => if active { state.l0_stat |= l0_regs::ETHERNET } else { state.l0_stat &= !l0_regs::ETHERNET }, IocInterrupt::Scsi1 => if active { state.l0_stat |= l0_regs::SCSI1 } else { state.l0_stat &= !l0_regs::SCSI1 }, IocInterrupt::Scsi0 => if active { state.l0_stat |= l0_regs::SCSI0 } else { state.l0_stat &= !l0_regs::SCSI0 }, - IocInterrupt::FifoFull => if active { state.l0_stat |= l0_regs::FIFO_FULL } else { state.l0_stat &= !l0_regs::FIFO_FULL }, + IocInterrupt::FifoFull => { + if self.guinness { + if active { state.l0_stat |= l0_regs::FIFO_FULL } else { state.l0_stat &= !l0_regs::FIFO_FULL } + } else { + state.set_extio_sg(extio_regs::SG_IRQ_2, active); + } + } // Local 1 - IocInterrupt::VerticalRetrace => if active { state.l1_stat |= l1_regs::VERTICAL_RETRACE } else { state.l1_stat &= !l1_regs::VERTICAL_RETRACE }, + IocInterrupt::VerticalRetrace => { + if self.guinness { + if active { state.l1_stat |= l1_regs::VERTICAL_RETRACE } else { state.l1_stat &= !l1_regs::VERTICAL_RETRACE } + } else { + state.set_extio_sg(extio_regs::SG_RETRACE, active); + } + } IocInterrupt::VideoVsync => if active { state.l1_stat |= l1_regs::VIDEO_VSYNC } else { state.l1_stat &= !l1_regs::VIDEO_VSYNC }, IocInterrupt::AcFail => if active { state.l1_stat |= l1_regs::AC_FAIL } else { state.l1_stat &= !l1_regs::AC_FAIL }, IocInterrupt::HpcDma => if active { state.l1_stat |= l1_regs::HPC_DMA } else { state.l1_stat &= !l1_regs::HPC_DMA }, @@ -323,9 +368,32 @@ impl Ioc { IocInterrupt::Serial => if active { state.map_stat |= map_regs::SERIAL } else { state.map_stat &= !map_regs::SERIAL }, IocInterrupt::KbMouse => if active { state.map_stat |= map_regs::KBD_MOUSE } else { state.map_stat &= !map_regs::KBD_MOUSE }, IocInterrupt::GioExp0 => { - if active { state.map_stat |= map_regs::GIO_EXP0 } else { state.map_stat &= !map_regs::GIO_EXP0 } + if self.guinness { + if active { state.map_stat |= map_regs::GIO_EXP0 } else { state.map_stat &= !map_regs::GIO_EXP0 } + } else { + state.set_extio_s0(extio_regs::S0_IRQ_1, active); + } + } + IocInterrupt::GioExp0Retrace => { + if !self.guinness { + state.set_extio_s0(extio_regs::S0_RETRACE, active); + } + } + IocInterrupt::GioExp1 => { + if self.guinness { + if active { state.map_stat |= map_regs::GIO_EXP1 } else { state.map_stat &= !map_regs::GIO_EXP1 } + } + } + IocInterrupt::GfxDrain0 => { + if !self.guinness { + if active { state.map_stat |= map_regs::GFX_DRAIN0 } else { state.map_stat &= !map_regs::GFX_DRAIN0 } + } + } + IocInterrupt::GfxDrain1 => { + if !self.guinness { + if active { state.map_stat |= map_regs::GFX_DRAIN1 } else { state.map_stat &= !map_regs::GFX_DRAIN1 } + } } - IocInterrupt::GioExp1 => if active { state.map_stat |= map_regs::GIO_EXP1 } else { state.map_stat &= !map_regs::GIO_EXP1 }, IocInterrupt::Mappable0 => if active { state.map_stat |= 1 << 0 } else { state.map_stat &= !(1 << 0) }, IocInterrupt::Mappable1 => if active { state.map_stat |= 1 << 1 } else { state.map_stat &= !(1 << 1) }, IocInterrupt::Mappable2 => if active { state.map_stat |= 1 << 2 } else { state.map_stat &= !(1 << 2) }, @@ -432,8 +500,8 @@ impl Device for Ioc { let _ = writeln!(writer, " MAP_POL={:02x} ERR_STAT={:02x}", s.map_pol, s.err_stat); let _ = writeln!(writer, " CPU IP lines: IP2={} IP3={} IP4=TMR0:{} IP5=TMR1:{} IP6=ERR:{}", ip2, ip3, ip4, ip5, ip6); - let _ = writeln!(writer, " Misc: sys_id={:02x} gc_select={:02x} gen_cntl={:02x} panel={:02x} read_reg={:02x} dma_sel={:02x} reset_reg={:02x} write_reg={:02x}", - s.sys_id, s.gc_select, s.gen_cntl, s.panel, s.read_reg, s.dma_sel, s.reset_reg, s.write_reg); + let _ = writeln!(writer, " Misc: sys_id={:02x} gc_select={:02x} extio={:08x} gen_cntl={:02x} panel={:02x} read_reg={:02x} dma_sel={:02x} reset_reg={:02x} write_reg={:02x}", + s.sys_id, s.gc_select, s.extio, s.gen_cntl, s.panel, s.read_reg, s.dma_sel, s.reset_reg, s.write_reg); if let Some(ints) = &s.interrupts { let raw = ints.load(Ordering::SeqCst); let _ = writeln!(writer, " Atomic interrupts word: {:016x} (IP2={} IP3={} IP4={} IP5={} IP6={} IP7=TMR:{})", @@ -568,7 +636,10 @@ impl BusDevice for Ioc { state.map_stat &= !(val & 0x3); } - IOC_GC_SELECT => state.gc_select = val, + IOC_GC_SELECT => { + state.gc_select = val; + state.apply_extio_fanout(); + } IOC_GEN_CNTL => state.gen_cntl = val, IOC_PANEL => { // Bits 6, 4, 1 are W1C (Write 1 to Clear) @@ -640,6 +711,41 @@ impl BusDevice for Ioc { } impl IocState { + fn set_extio_sg(&mut self, mask: u32, active: bool) { + if active { self.extio |= mask; } else { self.extio &= !mask; } + self.apply_extio_fanout(); + } + + fn set_extio_s0(&mut self, mask: u32, active: bool) { + if active { self.extio |= mask; } else { self.extio &= !mask; } + self.apply_extio_fanout(); + } + + /// Route shared GIO IRQ lines through gc_select (bit 0: 0 = SG/gfx slot, 1 = S0). + fn apply_extio_fanout(&mut self) { + if !self.fullhouse { + return; + } + let sel = self.extio + & if self.gc_select & 1 == 0 { extio_regs::SG_MASK } else { extio_regs::S0_MASK }; + + if sel & (extio_regs::SG_IRQ_2 | extio_regs::S0_IRQ_2) != 0 { + self.l0_stat |= l0_regs::FIFO_FULL; + } else { + self.l0_stat &= !l0_regs::FIFO_FULL; + } + if sel & (extio_regs::SG_IRQ_1 | extio_regs::S0_IRQ_1) != 0 { + self.l0_stat |= l0_regs::GRAPHICS; + } else { + self.l0_stat &= !l0_regs::GRAPHICS; + } + if sel & (extio_regs::SG_RETRACE | extio_regs::S0_RETRACE) != 0 { + self.l1_stat |= l1_regs::VERTICAL_RETRACE; + } else { + self.l1_stat &= !l1_regs::VERTICAL_RETRACE; + } + } + fn update_interrupts(&mut self) { // 1. Update Mappable Interrupts (MAP_INT0, MAP_INT1) let map_int0 = (self.map_stat & self.map_mask0) != 0; @@ -721,6 +827,7 @@ impl Resettable for Ioc { state.map_pol = 0; state.err_stat = 0; state.gc_select = 0; + state.extio = 0; state.gen_cntl = 0; state.panel = 1; // power-on: power state bit = 1 state.read_reg = 0x70; // ethernet/SCSI power good @@ -742,7 +849,8 @@ impl Saveable for Ioc { macro_rules! u8f { ($f:ident) => { tbl.insert(stringify!($f).into(), hex_u8(state.$f)); } } u8f!(l0_stat); u8f!(l0_mask); u8f!(l1_stat); u8f!(l1_mask); u8f!(map_stat); u8f!(map_mask0); u8f!(map_mask1); u8f!(map_pol); u8f!(err_stat); - u8f!(gc_select); u8f!(gen_cntl); u8f!(panel); u8f!(read_reg); + u8f!(gc_select); tbl.insert("extio".into(), toml::Value::String(format!("0x{:08x}", state.extio))); + u8f!(gen_cntl); u8f!(panel); u8f!(read_reg); u8f!(dma_sel); u8f!(reset_reg); u8f!(write_reg); toml::Value::Table(tbl) } @@ -754,8 +862,17 @@ impl Saveable for Ioc { }} ldu8!(l0_stat); ldu8!(l0_mask); ldu8!(l1_stat); ldu8!(l1_mask); ldu8!(map_stat); ldu8!(map_mask0); ldu8!(map_mask1); ldu8!(map_pol); ldu8!(err_stat); - ldu8!(gc_select); ldu8!(gen_cntl); ldu8!(panel); ldu8!(read_reg); + ldu8!(gc_select); + if let Some(x) = get_field(v, "extio") { + if let Some(s) = x.as_str() { + state.extio = u32::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(state.extio); + } else if let Some(n) = x.as_integer() { + state.extio = n as u32; + } + } + ldu8!(gen_cntl); ldu8!(panel); ldu8!(read_reg); ldu8!(dma_sel); ldu8!(reset_reg); ldu8!(write_reg); + state.apply_extio_fanout(); state.update_interrupts(); Ok(()) } diff --git a/src/iris_ci_main.rs b/src/iris_ci_main.rs index 5fbb897..7c3164d 100644 --- a/src/iris_ci_main.rs +++ b/src/iris_ci_main.rs @@ -20,11 +20,18 @@ use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; -use std::net::Shutdown; -use std::os::unix::net::UnixStream; +use std::net::TcpStream; use std::path::PathBuf; use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +#[cfg(unix)] +const DEFAULT_SOCKET: &str = "/tmp/iris.sock"; +#[cfg(windows)] +const DEFAULT_SOCKET: &str = "127.0.0.1:19851"; +#[cfg(not(any(unix, windows)))] const DEFAULT_SOCKET: &str = "/tmp/iris.sock"; const PROMPT_RE: &str = "IRIS"; // Match "IRIS N# " — N is a counter that increments const RC_MARKER: &str = "IRIS-CI-RC="; @@ -322,21 +329,48 @@ struct Opts { /// terminated response line, then drop the stream — the server's reader /// loop sees EOF and exits cleanly. fn send(opts: &Opts, cmd: &str, args: Value) -> Result { - let s = UnixStream::connect(&opts.socket)?; + let addr = opts.socket.to_string_lossy(); + if iris::config::ci_socket_is_tcp(&addr) { + send_tcp(&iris::config::ci_socket_tcp_addr(&addr), cmd, args) + } else { + #[cfg(unix)] + { + send_unix(&opts.socket, cmd, args) + } + #[cfg(not(unix))] + { + Err(Error::Iris(format!( + "CI socket {} is not TCP; on this platform use host:port (default {})", + addr, DEFAULT_SOCKET + ))) + } + } +} + +fn send_tcp(addr: &str, cmd: &str, args: Value) -> Result { + let s = TcpStream::connect(addr)?; + s.set_read_timeout(Some(Duration::from_secs(300))).ok(); + let mut writer = s.try_clone()?; + let mut reader = BufReader::new(s); + exchange(&mut reader, &mut writer, cmd, args) +} + +#[cfg(unix)] +fn send_unix(socket: &PathBuf, cmd: &str, args: Value) -> Result { + let s = UnixStream::connect(socket)?; s.set_read_timeout(Some(Duration::from_secs(300))).ok(); + let mut writer = s.try_clone()?; + let mut reader = BufReader::new(s); + exchange(&mut reader, &mut writer, cmd, args) +} + +fn exchange(reader: &mut impl BufRead, writer: &mut impl Write, cmd: &str, args: Value) -> Result { let req = json!({"cmd": cmd, "args": args}); let line = format!("{}\n", serde_json::to_string(&req).expect("json")); - { - let mut writer = s.try_clone()?; - writer.write_all(line.as_bytes())?; - writer.flush()?; - } - // Read exactly one line of response. - let mut reader = BufReader::new(s.try_clone()?); + writer.write_all(line.as_bytes())?; + writer.flush()?; let mut buf = String::new(); reader.read_line(&mut buf)?; - // Tell the server we're done so its read loop exits. - let _ = s.shutdown(Shutdown::Both); let trimmed = buf.trim(); if trimmed.is_empty() { return Err(Error::Iris("empty response".into())); diff --git a/src/jit/dispatch.rs b/src/jit/dispatch.rs index 7aea757..fde42d6 100644 --- a/src/jit/dispatch.rs +++ b/src/jit/dispatch.rs @@ -73,6 +73,16 @@ use super::trace::{TraceWriter, TraceRecord}; const MAX_BLOCK_LEN: usize = 64; +/// Whether a stable block may leave speculative mode (snapshot/rollback path). +/// x86_64 Keeps Loads speculative — multi-load blocks miscompile when trusted. +fn speculative_may_graduate(tier: BlockTier) -> bool { + match tier { + BlockTier::Alu => true, + BlockTier::Loads => cfg!(target_arch = "aarch64"), + BlockTier::Full => false, + } +} + /// How many interpreter steps in one outer batch (controls flush_cycles frequency). const BATCH_SIZE: u32 = 10000; @@ -234,6 +244,9 @@ pub fn run_jit_dispatch( let mut profile_replayed: u64 = 0; let mut profile_stale: u64 = 0; + #[cfg(feature = "idle-pause")] + let mut idle_state = crate::idle_park::IdleParkState::default(); + while running.load(Ordering::Relaxed) { let mut steps_in_batch: u32 = 0; @@ -673,21 +686,32 @@ pub fn run_jit_dispatch( block.stable_hits += 1; block.exception_count = 0; - if block.speculative && block.stable_hits >= tier_cfg.stable { + // Alu may graduate on all hosts; Loads only on aarch64 (x86_64 + // regalloc2 miscompiles multi-load blocks — keep Loads speculative). + // Full-tier load-only blocks never graduate (premiere TLBMISS). + if block.speculative + && block.stable_hits >= tier_cfg.stable + && speculative_may_graduate(block.tier) + { block.speculative = false; } if !block.speculative && block.stable_hits >= tier_cfg.promote { if let Some(next) = block.tier.promote().filter(|t| *t <= max_tier) { - promotions += 1; - eprintln!("JIT: promote {:016x} {:?}→{:?} ({}hits)", - pc, block.tier, next, block.hit_count); - let instrs = trace_block(exec, pc, next); - if !instrs.is_empty() { - async_comp.submit(CompileRequest { - instrs, block_pc: pc, phys_pc, - tier: next, kind: CompileKind::Recompile, - }); + if !async_comp.pending.contains(&(phys_pc, pc)) { + promotions += 1; + eprintln!("JIT: promote {:016x} {:?}→{:?} ({}hits)", + pc, block.tier, next, block.hit_count); + let instrs = trace_block(exec, pc, next); + if !instrs.is_empty() { + async_comp.submit(CompileRequest { + instrs, block_pc: pc, phys_pc, + tier: next, kind: CompileKind::Recompile, + }); + } + if let Some(block) = cache.lookup_mut(phys_pc, pc) { + block.stable_hits = 0; + } } } } @@ -885,7 +909,10 @@ pub fn run_jit_dispatch( if !cache.contains(phys_pc, entry.virt_pc) && !async_comp.pending.contains(&(phys_pc, entry.virt_pc)) { - let instrs = trace_block(exec, entry.virt_pc, entry.tier); + // Profile hints are Loads-tier at most; Full blocks are + // promoted in-session with speculative rollback intact. + let replay_tier = entry.tier.min(max_tier).min(BlockTier::Loads); + let instrs = trace_block(exec, entry.virt_pc, replay_tier); if !instrs.is_empty() { let content_hash = super::compiler::hash_block_instrs(&instrs); if instrs.len() as u32 == entry.len_mips @@ -893,7 +920,7 @@ pub fn run_jit_dispatch( { async_comp.submit(CompileRequest { instrs, block_pc: entry.virt_pc, phys_pc, - tier: entry.tier, + tier: replay_tier, kind: CompileKind::ProfileReplay { content_hash }, }); } else { @@ -915,6 +942,14 @@ pub fn run_jit_dispatch( exec.flush_cycles(); } + #[cfg(feature = "idle-pause")] + if crate::idle_park::idle_park_enabled() { + let exec = unsafe { &mut *exec_ptr }; + if idle_state.update(&exec.core) { + idle_state.park(&mut exec.core, running); + } + } + // Write trace record at 100K instruction milestones. // Both JIT and interpreter runs log at the same milestones so // records align for offline comparison. @@ -1000,7 +1035,7 @@ pub fn run_jit_dispatch( .map(|(&(phys_pc, _virt_pc), block)| ProfileEntry { phys_pc, virt_pc: block.virt_addr, - tier: block.tier, + tier: block.tier.min(BlockTier::Loads), len_mips: block.len_mips, content_hash: block.content_hash, hit_count: block.hit_count, @@ -1086,13 +1121,10 @@ fn trace_block( let mut instrs = Vec::with_capacity(max_len); let mut pc = start_pc; - // Full-tier blocks accumulate up to max_helpers load/store helper calls - // before terminating. Each helper emits an ok_block/exc_block CFG diamond. - // Too many chained diamonds trip Cranelift's regalloc2 and produce wrong - // code (confirmed by IRIS_JIT_VERIFY catching real GPR mismatches). The - // safe ceiling was empirically determined: aarch64 tolerates 3, x86_64 - // only 1. Bumping past this threshold produces silent miscompilations. - let max_helpers: u32 = MAX_BLOCK_LEN as u32; + // Loads/Full: each load helper emits an ok_block/exc_block CFG diamond. + // Too many chained diamonds trip Cranelift regalloc2 on x86_64 (limit 1); + // aarch64 tolerates 3. See rules/jit/cranelift-regalloc2-helper-diamond-limit. + let max_helpers: u32 = if cfg!(target_arch = "aarch64") { 3 } else { 1 }; let mut helper_count: u32 = 0; for _ in 0..max_len { @@ -1123,7 +1155,8 @@ fn trace_block( break; } - let is_helper_instr = tier == BlockTier::Full && is_compilable_load(&d); + let is_helper_instr = + tier >= BlockTier::Loads && is_compilable_load(&d); instrs.push((raw, d)); if is_helper_instr { diff --git a/src/lib.rs b/src/lib.rs index 29bfd7d..9cb401a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod build_features { /// 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 const IDLE_PAUSE: bool = cfg!(feature = "idle-pause"); /// The emulated CPU, fixed at build time (the cache model differs deeply /// between the R4400 and R5000, so it's a compile-time choice, not a runtime /// setting). `r5k` selects the R5000; `r5ksc`/`r5ksc_triton` add a secondary @@ -74,10 +75,13 @@ pub mod hal2; pub mod ps2; pub mod ui; pub mod rex3; +pub mod rex3_simd; pub mod compositor; pub mod gl_compositor; +pub mod headless_gl; pub mod debug_overlay; pub mod vc2; +pub mod vc2_timings; pub mod xmap9; pub mod cmap; pub mod bt445; @@ -89,9 +93,13 @@ pub mod sgi_vh; pub mod chunk_store; pub mod validate; pub mod registry; +pub mod thread_affinity; +pub mod perf_monitor; pub mod ci; pub mod hptimer; pub mod hptimer_tests; +#[cfg(feature = "idle-pause")] +pub mod idle_park; pub mod vga_font; pub mod cdmc; #[cfg(feature = "camera")] @@ -99,9 +107,14 @@ pub mod camera; pub mod saa7191; pub mod video_source; pub mod vino; +pub mod xz; +pub mod mgras; pub mod ultra_proto; pub mod ultra64; #[cfg(feature = "jit")] pub mod jit; #[cfg(feature = "rex-jit")] -pub mod rex3_jit; \ No newline at end of file +pub mod rex3_jit; + +#[cfg(test)] +mod platform_profile_tests; \ No newline at end of file diff --git a/src/machine.rs b/src/machine.rs index fc101a7..b879e5f 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -6,7 +6,7 @@ use std::net::TcpStream; use std::sync::mpsc; use std::thread; -use crate::config::{MachineConfig, NetworkConfig}; +use crate::config::{GraphicsBoard, MachineConfig, MachineProfile, NetworkConfig}; use crate::traits::{BusDevice, Device, Resettable, Saveable, MachineEvent}; use crate::locks::LockMonitor; use crate::eeprom_93c56::Eeprom93c56; @@ -48,7 +48,7 @@ pub fn emulator_name() -> &'static str { } let firsts = ["Irresponsible", "Incredible", "Insufferable", "Infuriating", "Inaccurate", "Incomplete", "Interactive", "Indomitable"]; - let thirds = ["IRIX", "Indy", "Iris", "IP22"]; + let thirds = ["IRIX", "Indy", "Iris"]; let fourths = ["Simulator", "System", "Substitute", "Sandbox"]; let first = firsts[((now / 4) % firsts.len() as u64) as usize]; @@ -93,6 +93,12 @@ pub struct Machine { disks: Vec, /// Configured nvram file path, recorded in snapshot manifests. nvram_path: String, + /// Host-forced Newport resolution from `[graphics] resolution` at construction. + display_resolution: crate::vc2_timings::NewportResolution, + /// Whether Newport compositor is active (not headless / not XZ board). + newport_active: bool, + /// Indigo2 IP22 fullhouse layout (`!guinness`). + fullhouse: bool, } /// In-memory snapshot of the just-restored guest state. Populated at the end @@ -126,6 +132,7 @@ struct RollbackCheckpoint { seeq: toml::Value, hpc3: toml::Value, rex3: Option, + rex3_head1: Option, } impl Machine { @@ -133,6 +140,19 @@ impl Machine { // Capture config flags that are needed after the local `cfg` binding // is shadowed later in this function. let ci_enabled = cfg.ci; + let perf = cfg.perf.clone(); + let display_resolution = cfg.graphics.resolution; + let newport_active = !cfg.headless && cfg.graphics.board == GraphicsBoard::Newport; + + if !cfg.machine.profile.supported() { + eprintln!( + "iris: machine profile \"{}\" is not implemented; use {}", + cfg.machine.profile.label(), + MachineProfile::IndyIp24.label(), + ); + std::process::exit(1); + } + let guinness = cfg.machine.profile.guinness(); // 0. Shared EEPROM let eeprom = Arc::new(Mutex::new(Eeprom93c56::new())); @@ -148,7 +168,7 @@ impl Machine { // 1. Create all devices first // Memory Controller - let mc = MemoryController::new(eeprom.clone(), true, cfg.banks); + let mc = MemoryController::new(eeprom.clone(), guinness, cfg.banks); // RAM banks sized per config. addr_mask is initialized to mem_size-1; // remap_banks() updates it via set_addr_mask() when MEMCFG0/1 are written during POST. @@ -174,7 +194,7 @@ impl Machine { // HPC3 (512KB at 0x1FB80000). CI mode skips the SCC TCP backend // bindings so multiple `--ci` instances can coexist. - let ioc = if ci_enabled { Ioc::new_ci(true) } else { Ioc::new(true) }; + let ioc = if ci_enabled { Ioc::new_ci(guinness) } else { Ioc::new(guinness) }; // CI mode replaces the default TCP backend on channel B (tty1, the // SGI serial console) with an in-process backend the control socket @@ -214,7 +234,7 @@ impl Machine { let timer_manager = Arc::new(TimerManager::new()); ioc.set_timer_manager(timer_manager.clone()); ioc.set_heartbeat(heartbeat.clone()); - let hpc3 = Hpc3::with_net(eeprom.clone(), ioc.clone(), true, heartbeat.clone(), cfg.network(), cfg.no_audio, cfg.nvram.clone(), cycles.clone(), cfg.scsi_deferred_int); + let hpc3 = Hpc3::with_net(eeprom.clone(), ioc.clone(), guinness, heartbeat.clone(), cfg.network(), cfg.no_audio, cfg.audio.clone(), cfg.nvram.clone(), cycles.clone(), cfg.scsi_deferred_int); hpc3.set_timer_manager(timer_manager.clone()); // Attach SCSI devices from config (IDs 1–7). @@ -324,19 +344,76 @@ impl Machine { disk_provenance.sort_by_key(|d| d.id); let nvram_provenance = cfg.nvram.clone(); - // REX3 Graphics — skipped in headless mode - let rex3: Option> = if cfg.headless { + // REX3 Graphics — Newport only; skipped in headless mode or when XZ board selected + let rex3: Option> = if cfg.headless || cfg.graphics.board != crate::config::GraphicsBoard::Newport { None } else { - let r = Arc::new(Rex3::new(heartbeat, cycles.clone(), fasttick_count.clone(), decoded_count.clone(), Arc::clone(&l1i_hit_count), Arc::clone(&l1i_fetch_count), Arc::clone(&uncached_fetch_count))); - // Connect VBlank interrupt to IOC + let r = Arc::new(Rex3::new(heartbeat.clone(), cycles.clone(), fasttick_count.clone(), decoded_count.clone(), Arc::clone(&l1i_hit_count), Arc::clone(&l1i_fetch_count), Arc::clone(&uncached_fetch_count))); let ioc_clone = ioc.clone(); r.set_vblank_callback(Arc::new(move |active| { + // Vertical retrace → L1 VERTICAL_RETRACE on Indy; on fullhouse the IOC + // fans extio SG_RETRACE into the same L1 bit (see apply_extio_fanout). + // GfxDrain0/1 are MAP FIFO-drain feedback — not vblank. ioc_clone.set_interrupt(crate::ioc::IocInterrupt::VerticalRetrace, active); })); + let ioc_ff = ioc.clone(); + r.set_fifo_full_callback(Arc::new(move |active| { + ioc_ff.set_interrupt(crate::ioc::IocInterrupt::FifoFull, active); + })); + let ioc_gfx = ioc.clone(); + r.set_graphics_callback(Arc::new(move |active| { + ioc_gfx.set_interrupt(crate::ioc::IocInterrupt::Graphics, active); + })); + let ioc_drain = ioc.clone(); + r.set_gfx_drain_callback(Arc::new(move |active| { + if guinness { + // Indy integrated Newport: drain feedback unused on MAP (guinness). + let _ = active; + } else { + ioc_drain.set_interrupt(crate::ioc::IocInterrupt::GfxDrain0, active); + } + })); + Some(r) + }; + + let rex3_head1: Option> = if cfg.headless || cfg.graphics.heads < 2 { + None + } else { + let r = Arc::new(Rex3::new(heartbeat, cycles.clone(), fasttick_count.clone(), decoded_count.clone(), Arc::clone(&l1i_hit_count), Arc::clone(&l1i_fetch_count), Arc::clone(&uncached_fetch_count))); + let ioc_clone = ioc.clone(); + r.set_vblank_callback(Arc::new(move |active| { + if guinness { + ioc_clone.set_interrupt(crate::ioc::IocInterrupt::GioExp1, active); + } else { + // Second Newport @ GIO slot 1: retrace via extio S0 (gc_select=1). + ioc_clone.set_interrupt(crate::ioc::IocInterrupt::GioExp0Retrace, active); + } + })); + let ioc_ff = ioc.clone(); + r.set_fifo_full_callback(Arc::new(move |active| { + ioc_ff.set_interrupt(crate::ioc::IocInterrupt::FifoFull, active); + })); + let ioc_gfx = ioc.clone(); + r.set_graphics_callback(Arc::new(move |active| { + ioc_gfx.set_interrupt(crate::ioc::IocInterrupt::Graphics, active); + })); Some(r) }; + // Indy XZ/Elan preview stub — same GIO gfx slot as Newport, no compositor. + let xz: Option> = if guinness && cfg.graphics.board == crate::config::GraphicsBoard::Xz { + Some(Arc::new(crate::xz::Xz::new())) + } else { + None + }; + + // Indigo2 IMPACT/MGRAS preview — multi-slot GIO stub. + let mgras: Option> = if !guinness && cfg.impact.any_enabled() { + Some(Arc::new(crate::mgras::Mgras::new(&cfg.impact))) + } else { + None + }; + // N64 development board (Ultra64) — GIO slot 0 at 0x1F400000 #[cfg(feature = "ultra64")] let ultra64: Option> = if cfg.ultra64.enabled { @@ -366,7 +443,10 @@ impl Machine { // 2. Create Physical Bus with devices let phys_raw = Physical::new( banks, - rex3, + rex3.clone(), + rex3_head1.clone(), + xz.clone(), + mgras.clone(), #[cfg(feature = "ultra64")] ultra64, vino, @@ -444,7 +524,9 @@ impl Machine { crate::config::VinoSource::Off => None, }; if let Some(source) = source { - phys.vino.set_source(source); + let adjusted: Arc = + Arc::new(crate::video_source::CdmcAdjustedSource::new(source, phys.vino.clone())); + phys.vino.set_source(adjusted); phys.vino.start(); } @@ -498,9 +580,19 @@ impl Machine { monitor.register_device(Arc::new(hpc3.clone())); monitor.register_device(phys.clone()); if let Some(rex3) = &phys.rex3 { monitor.register_device(rex3.clone()); } + if let Some(rex3) = &phys.rex3_head1 { monitor.register_device(rex3.clone()); } + if let Some(xz) = &phys.xz { monitor.register_device(xz.clone()); } + if let Some(mgras) = &phys.mgras { monitor.register_device(mgras.clone()); } #[cfg(feature = "ultra64")] if let Some(u64) = &phys.ultra64 { monitor.register_device(u64.clone()); } monitor.register_device(Arc::new(phys.vino.clone())); + monitor.register_device(crate::perf_monitor::PerfMonitor::new( + cpu.running_flag(), + cpu.cycles_counter(), + cpu.fasttick_count.clone(), + phys.rex3.clone(), + hpc3.hal2().cloned(), + )); let monitor = Arc::new(monitor); // Register lock monitor device and all component locks @@ -511,6 +603,7 @@ impl Machine { mc.register_locks(); hpc3.register_locks(); if let Some(rex3) = &phys.rex3 { rex3.register_locks(); } + if let Some(rex3) = &phys.rex3_head1 { rex3.register_locks(); } cpu.register_locks(); } { @@ -524,6 +617,8 @@ impl Machine { mc.set_event_sender(event_tx.clone()); ioc.set_event_sender(event_tx.clone()); + crate::thread_affinity::init(perf); + Self { cpu, _phys: phys, @@ -540,6 +635,33 @@ impl Machine { scratch_path, disks: disk_provenance, nvram_path: nvram_provenance, + display_resolution, + newport_active, + fullhouse: !guinness, + } + } + + fn apply_host_display_resolution(&self) { + if !self.newport_active { + return; + } + let mode = if self.display_resolution.is_guest() { + // Fullhouse + guest-selected resolution: IRIX/PROM may never program VC2 + // (embedded Indy PROM, gfxinit mismatch). Bootstrap 1280×1024 so the GUI + // refresh thread has non-zero dimensions; guest can reprogram VC2 later. + if self.fullhouse { + crate::vc2_timings::NewportResolution::Res1280x1024 + } else { + return; + } + } else { + self.display_resolution + }; + if let Some(rex3) = &self._phys.rex3 { + rex3.apply_display_resolution(mode); + } + if let Some(rex3) = &self._phys.rex3_head1 { + rex3.apply_display_resolution(mode); } } @@ -571,7 +693,10 @@ impl Machine { // Start peripherals self.mc.start(); self.hpc3.start(); + // Program VC2 before the refresh thread runs so the first frame has size. + self.apply_host_display_resolution(); if let Some(rex3) = &self._phys.rex3 { rex3.start(); } + if let Some(rex3) = &self._phys.rex3_head1 { rex3.start(); } #[cfg(feature = "ultra64")] if let Some(u64) = &self._phys.ultra64 { u64.start(); } @@ -646,6 +771,7 @@ impl Machine { pub fn stop(&mut self) { self.cpu.stop(); if let Some(rex3) = &self._phys.rex3 { rex3.stop(); } + if let Some(rex3) = &self._phys.rex3_head1 { rex3.stop(); } self.hpc3.stop(); self.mc.stop(); #[cfg(feature = "ultra64")] @@ -709,6 +835,10 @@ impl Machine { self._phys.rex3.clone() } + pub fn get_rex3_head1(&self) -> Option> { + self._phys.rex3_head1.clone() + } + pub fn get_timer_manager(&self) -> Arc { self.timer_manager.clone() } @@ -738,6 +868,26 @@ impl Machine { self.hpc3.ioc().scc().drain_console() } + /// csh one-liner to (re)mount `/CDROM` for SCSI unit `id` (EFS s7, else iso9660 vol). + pub fn irix_cdrom_remount_command(scsi_id: u8) -> String { + format!( + "umount /CDROM >& /dev/null; \ + mount -t efs -o ro /dev/dsk/dks0d{id}s7 /CDROM || \ + mount -t iso9660 /dev/rdsk/dks0d{id}vol /CDROM\n", + id = scsi_id + ) + } + + /// Best-effort `/CDROM` remount via the IRIX serial console (tty1). + /// Works when a login shell or xterm on the console is active. + pub fn remount_cdrom_guest(&self, scsi_id: u8) { + let script = Self::irix_cdrom_remount_command(scsi_id); + eprintln!( + "IRIX: remount /CDROM for SCSI #{scsi_id} (console shell must be active)" + ); + self.inject_serial_console(script.as_bytes()); + } + /// CPU thread, started explicitly by the CI `start` command or by /// `ci_restore`. In `--ci` mode the CPU is not autostarted in `start()` /// — the harness drives startup via `restore`. @@ -913,6 +1063,7 @@ impl Machine { let seeq = self.hpc3.seeq().save_state(); let hpc3 = self.hpc3.save_state(); let rex3 = self._phys.rex3.as_ref().map(|r| r.save_state()); + let rex3_head1 = self._phys.rex3_head1.as_ref().map(|r| r.save_state()); let bank_words: [Vec; 4] = [ self._phys.snapshot_bank_inmem(0), @@ -952,7 +1103,7 @@ impl Machine { overlay_sets, bank_words, framebuffers, - cpu, mc, ioc, scc, pit, ps2, rtc, eeprom, scsi, seeq, hpc3, rex3, + cpu, mc, ioc, scc, pit, ps2, rtc, eeprom, scsi, seeq, hpc3, rex3, rex3_head1, }) } @@ -976,6 +1127,9 @@ impl Machine { if let (Some(rex3), Some(rex3_toml)) = (&self._phys.rex3, &cp.rex3) { rex3.load_state(rex3_toml)?; } + if let (Some(rex3), Some(rex3_toml)) = (&self._phys.rex3_head1, &cp.rex3_head1) { + rex3.load_state(rex3_toml)?; + } for (i, words) in cp.bank_words.iter().enumerate() { self._phys.restore_bank_inmem(i, words); @@ -1000,6 +1154,7 @@ impl Machine { self.mc.start(); self.hpc3.start(); if let Some(rex3) = &self._phys.rex3 { rex3.start(); } + if let Some(rex3) = &self._phys.rex3_head1 { rex3.start(); } } /// Helper to power-on reset all devices. @@ -1027,6 +1182,8 @@ impl Machine { if let Some(hal2) = self.hpc3.hal2() { hal2.power_on(); } self.hpc3.power_on(); if let Some(rex3) = &self._phys.rex3 { rex3.power_on(); } + if let Some(rex3) = &self._phys.rex3_head1 { rex3.power_on(); } + self.apply_host_display_resolution(); } /// Stop all threads, power-on reset every device in-place, restart peripherals. @@ -1084,6 +1241,19 @@ impl Machine { rex3.save_framebuffers(&snap.dir).map_err(|e| e.to_string())?; } } + if let Some(rex3) = &self._phys.rex3_head1 { + snap.write_state("rex3_head1", &rex3.save_state(), sv).map_err(|e| e.to_string())?; + if sv < 3 { + let dir = &snap.dir; + rex3.save_framebuffers_named(dir, "rex3_head1").map_err(|e| e.to_string())?; + } + } + if let Some(xz) = &self._phys.xz { + snap.write_state("xz", &xz.save_state(), sv).map_err(|e| e.to_string())?; + } + if let Some(mgras) = &self._phys.mgras { + snap.write_state("mgras", &mgras.save_state(), sv).map_err(|e| e.to_string())?; + } // Bulk memory: v3+ goes to the content-addressable chunk store // shared across all snapshots in `saves/.cas/`. v2 (legacy) writes @@ -1156,6 +1326,7 @@ impl Machine { self.hpc3.stop(); self.mc.stop(); if let Some(rex3) = &self._phys.rex3 { rex3.stop(); } + if let Some(rex3) = &self._phys.rex3_head1 { rex3.stop(); } Ok(()) } @@ -1306,11 +1477,28 @@ impl Machine { if let Some(rex3) = &self._phys.rex3 { let rex3_v = snap.read_state("rex3", schema_version).map_err(|e| e.to_string())?; rex3.load_state(&rex3_v)?; - // v3+ stores framebuffers in the chunk store; v2 used .bin files. if schema_version < 3 { rex3.load_framebuffers(&snap.dir).map_err(|e| e.to_string())?; } } + if let Some(rex3) = &self._phys.rex3_head1 { + if let Ok(rex3_v) = snap.read_state("rex3_head1", schema_version) { + rex3.load_state(&rex3_v)?; + if schema_version < 3 { + rex3.load_framebuffers_named(&snap.dir, "rex3_head1").map_err(|e| e.to_string())?; + } + } + } + if let Some(xz) = &self._phys.xz { + if let Ok(xz_v) = snap.read_state("xz", schema_version) { + xz.load_state(&xz_v)?; + } + } + if let Some(mgras) = &self._phys.mgras { + if let Ok(mgras_v) = snap.read_state("mgras", schema_version) { + mgras.load_state(&mgras_v)?; + } + } // Bulk memory: v3+ comes from the content-addressable chunk store // shared across snapshots; v2 reads raw bank{N}.bin files. diff --git a/src/main.rs b/src/main.rs index 9543ce1..d8be6cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,11 +32,8 @@ fn main() { let ci_display = cfg.ci_display; let ci_socket_path = cfg.ci_socket.clone(); - // CI control socket will be started after Machine::new below (it needs a - // pointer into the constructed Machine). - - // NFS is now served in-process by the NAT (src/nfsudp.rs) — no external - // unfsd to spawn. The directory is created on demand by the server. + // Apply [jit] from TOML (env vars still override if set externally). + cfg.jit.apply_env(); // Machine::new() allocates >1MB on the stack (Physical device_map), which overflows // the default stack on Windows (1MB). We spawn a thread with a larger stack to create it. @@ -50,7 +47,6 @@ fn main() { // CI control socket: started after Machine::new so it can hand out the // machine pointer + CiSerialBackend to command handlers. - #[cfg(unix)] let _ci_server = if ci_enabled { let mptr: *mut iris::machine::Machine = &mut *machine; match iris::ci::start_server(mptr, &ci_socket_path) { diff --git a/src/mc.rs b/src/mc.rs index 524ff10..f4e9703 100644 --- a/src/mc.rs +++ b/src/mc.rs @@ -218,9 +218,10 @@ impl MemoryController { // Initialize REF_CTR: Current Refresh Count regs[(REG_REF_CTR / 4) as usize] = 0x00000C30; - // Initialize GIO64_ARB: ONE_GIO=1 (0x400) + // Initialize GIO64_ARB: ONE_GIO=1 (0x400) on Indy (single GIO64 bus). + // Indigo2 fullhouse clears ONE_GIO for dual GIO64 buses. // Bit 0 (HPC_SIZE) is typically loaded from EEROM. Defaulting to 0 (32-bit) for now. - regs[(REG_GIO64_ARB / 4) as usize] = 0x00000400; + regs[(REG_GIO64_ARB / 4) as usize] = if guinness { 0x00000400 } else { 0x00000000 }; // Initialize CPU_TIME: 0x100 regs[(REG_CPU_TIME / 4) as usize] = 0x00000100; @@ -286,6 +287,29 @@ impl MemoryController { /// Rank inference from size_mb (matching the PROM's set_bank_size 0x2a check): /// inst_units = size_mb / 4MB. inst_rank=1 (dual) if inst_units & 0x2a != 0. /// inst_size_per_rank = size_mb_bytes >> inst_rank. + /// Encode a MEMCFG half-word for a bank at `base` with `size_mb` installed. + /// Inverse of [`memcfg_bank_info`] for the sizes IRIS supports. + pub fn encode_memcfg_half(base: u32, size_mb: u32) -> Option { + if size_mb == 0 { + return None; + } + let (simm_size_field, simm_rank): (u32, u32) = match size_mb { + 8 => (0, 1), + 16 => (3, 0), + 32 => (3, 1), + 64 => (15, 0), + 128 => (15, 1), + _ => return None, + }; + let base_byte = (base >> 22) & 0xFF; + Some( + (base_byte as u16) + | (1 << 13) // VLD + | ((simm_rank as u16) << 14) + | ((simm_size_field as u16) << 8), + ) + } + pub fn memcfg_bank_info(half: u16, size_mb: u32) -> Option<(u32, u32, u32)> { if size_mb == 0 { return None; } if (half >> 13) & 1 == 0 { return None; } @@ -327,7 +351,58 @@ impl MemoryController { std::array::from_fn(|i| Self::memcfg_bank_info(halves[i], self.ram_sizes[i])) } - fn fire_memcfg_callback(&self, state: &MemoryControllerState) { + /// If the embedded PROM POSTed lomem (banks 0–1) but skipped himem, synthesize + /// MEMCFG1 entries for configured banks 2–3 so IRIX sees extended RAM. + fn synthesize_himem_banks(&self, state: &mut MemoryControllerState) -> bool { + use crate::physical::{BANK_SIZE, HIMEM_BASE}; + + let memcfg0 = state.regs[(REG_MEMCFG0 / 4) as usize]; + let memcfg1 = state.regs[(REG_MEMCFG1 / 4) as usize]; + let h0 = (memcfg0 >> 16) as u16; + let h1 = (memcfg0 & 0xFFFF) as u16; + // Wait until PROM has mapped lomem before touching himem. + if (h0 >> 13) & 1 == 0 || (h1 >> 13) & 1 == 0 { + return false; + } + + let mut h2 = (memcfg1 >> 16) as u16; + let mut h3 = (memcfg1 & 0xFFFF) as u16; + let mut changed = false; + + if self.ram_sizes[2] > 0 && (h2 >> 13) & 1 == 0 { + if let Some(enc) = Self::encode_memcfg_half(HIMEM_BASE, self.ram_sizes[2]) { + h2 = enc; + changed = true; + } + } + if self.ram_sizes[3] > 0 && (h3 >> 13) & 1 == 0 { + if let Some(enc) = + Self::encode_memcfg_half(HIMEM_BASE + BANK_SIZE, self.ram_sizes[3]) + { + h3 = enc; + changed = true; + } + } + + if changed { + let new_memcfg1 = ((h2 as u32) << 16) | (h3 as u32); + state.regs[(REG_MEMCFG1 / 4) as usize] = new_memcfg1; + dlog_dev!( + LogModule::Mc, + "MC: synthesized MEMCFG1 for extended RAM = {:08x} (banks {:?})", + new_memcfg1, + self.ram_sizes + ); + eprintln!( + "MC: synthesized MEMCFG1 for extended RAM (banks {:?})", + self.ram_sizes + ); + } + changed + } + + fn on_memcfg_updated(&self, state: &mut MemoryControllerState) { + self.synthesize_himem_banks(state); if let Some(cb) = self.memcfg_callback.get() { let memcfg0 = state.regs[(REG_MEMCFG0 / 4) as usize]; let memcfg1 = state.regs[(REG_MEMCFG1 / 4) as usize]; @@ -899,13 +974,13 @@ impl BusDevice for MemoryController { REG_MEMCFG0 => { dlog_dev!(LogModule::Mc, "MC: Write MEMCFG0 = {:08x}", val); state.regs[(REG_MEMCFG0 / 4) as usize] = val; - self.fire_memcfg_callback(&state); + self.on_memcfg_updated(&mut state); BUS_OK } REG_MEMCFG1 => { dlog_dev!(LogModule::Mc, "MC: Write MEMCFG1 = {:08x}", val); state.regs[(REG_MEMCFG1 / 4) as usize] = val; - self.fire_memcfg_callback(&state); + self.on_memcfg_updated(&mut state); BUS_OK } REG_CPU_MEMACC => { @@ -1328,6 +1403,22 @@ impl Saveable for MemoryController { mod tests { use super::*; + #[test] + fn sysid_matches_guinness_profile() { + let eeprom = Arc::new(Mutex::new(Eeprom93c56::new())); + let indy = MemoryController::new(eeprom.clone(), true, [0u32; 4]); + let sysid = indy.read32(MC_BASE + REG_SYSID).data; + assert_eq!(sysid, 0x0000_0013, "Indy IP24 (guinness) SYSID"); + let gio_arb = indy.read32(MC_BASE + REG_GIO64_ARB).data; + assert_eq!(gio_arb & 0x400, 0x400, "Indy ONE_GIO set"); + + let fullhouse = MemoryController::new(eeprom, false, [0u32; 4]); + let sysid = fullhouse.read32(MC_BASE + REG_SYSID).data; + assert_eq!(sysid, 0x0000_0010, "Indigo2-class (fullhouse) SYSID"); + let gio_arb = fullhouse.read32(MC_BASE + REG_GIO64_ARB).data; + assert_eq!(gio_arb & 0x400, 0, "fullhouse dual GIO — ONE_GIO clear"); + } + #[test] fn test_mc_internal_access() { let eeprom = Arc::new(Mutex::new(Eeprom93c56::new())); @@ -1384,4 +1475,39 @@ mod tests { assert_eq!(v1, v2, "MemoryController save_state mismatch after load_state round-trip"); } + + #[test] + fn memcfg_encode_decode_roundtrip() { + use crate::physical::{BANK_SIZE, HIMEM_BASE, LOMEM_BASE}; + for (base, size) in [ + (LOMEM_BASE, 128u32), + (LOMEM_BASE + BANK_SIZE, 128), + (HIMEM_BASE, 64), + (HIMEM_BASE + BANK_SIZE, 64), + ] { + let half = MemoryController::encode_memcfg_half(base, size).unwrap(); + let info = MemoryController::memcfg_bank_info(half, size); + assert!(info.is_some(), "base={base:#x} size={size}"); + assert_eq!(info.unwrap().0, base); + } + } + + #[test] + fn himem_synthesis_after_lomem_post() { + use crate::physical::{BANK_SIZE, LOMEM_BASE}; + + let eeprom = Arc::new(Mutex::new(Eeprom93c56::new())); + let mc = MemoryController::new(eeprom, true, [128, 128, 64, 64]); + let h0 = MemoryController::encode_memcfg_half(LOMEM_BASE, 128).unwrap(); + let h1 = MemoryController::encode_memcfg_half(LOMEM_BASE + BANK_SIZE, 128).unwrap(); + let memcfg0 = ((h0 as u32) << 16) | (h1 as u32); + assert_eq!(mc.write32(MC_BASE + REG_MEMCFG0, memcfg0), BUS_OK); + + let (m0, m1) = mc.get_memcfg(); + let addrs = mc.parse_memcfg(m0, m1); + assert!(addrs[0].is_some() && addrs[1].is_some()); + assert!(addrs[2].is_some(), "bank 2 should be synthesized"); + assert!(addrs[3].is_some(), "bank 3 should be synthesized"); + assert_eq!(addrs[2].unwrap().0, crate::physical::HIMEM_BASE); + } } diff --git a/src/mgras.rs b/src/mgras.rs new file mode 100644 index 0000000..feb5e2b --- /dev/null +++ b/src/mgras.rs @@ -0,0 +1,347 @@ +/// IMPACT / MGRAS graphics — preview stub (Indigo2 IP22) +/// +/// Post-1995 IMPACT boards use the MGRAS ASIC set (geometry engine, raster +/// engine, TRAM controllers) spread across one to three GIO64 slots depending on +/// the option (Solid / High / Maximum). +/// +/// **Preview only:** per-slot register files with probe-friendly board IDs and +/// idle status. No TRAM, no DMA, no GL/command processing. +/// +/// GIO slot bases (physical, IP22): +/// gfx — 0x1F000000 (4 MB) +/// exp0 — 0x1F400000 (2 MB) +/// exp1 — 0x1F600000 (4 MB) +/// +/// See `docs/impact-mgras-research.md` for hardware notes and implementation status. + +use parking_lot::Mutex; +use std::io::Write as IoWrite; + +use crate::config::{ImpactSection, ImpactSlot}; +use crate::devlog::LogModule; +use crate::snapshot::{get_field, hex_u32, toml_u32}; +use crate::traits::{BusDevice, BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, Device, Saveable}; + +// ─── GIO slot geometry ─────────────────────────────────────────────────────── + +pub const MGRAS_SLOT_GFX_BASE: u32 = 0x1F00_0000; +pub const MGRAS_SLOT_GFX_SIZE: u32 = 0x0040_0000; +pub const MGRAS_SLOT_EXP0_BASE: u32 = 0x1F40_0000; +pub const MGRAS_SLOT_EXP0_SIZE: u32 = 0x0020_0000; +pub const MGRAS_SLOT_EXP1_BASE: u32 = 0x1F60_0000; +pub const MGRAS_SLOT_EXP1_SIZE: u32 = 0x0040_0000; + +/// MGRAS CPU register window within each populated slot (research placeholder; +/// mirrors the Newport/XZ `+0x0F0000` pattern until a verified map lands). +pub const MGRAS_REG_OFF: u32 = 0x000F_0000; +pub const MGRAS_REG_SIZE: u32 = 0x2000; + +// ─── Register offsets (relative to slot base + MGRAS_REG_OFF) ─────────────── + +pub mod reg { + use crate::config::ImpactSlot; + + pub const BOARD_ID: u32 = 0x0000; + pub const REVISION: u32 = 0x0004; + pub const STATUS: u32 = 0x0008; + pub const INTR_STATUS: u32 = 0x000C; + pub const INTR_ENABLE: u32 = 0x0010; + pub const FIFO_WRITE: u32 = 0x0018; + pub const SLOT_ROLE: u32 = 0x0024; // which GE/TRAM slice (multi-slot boards) + + pub const STATUS_IDLE: u32 = 0x0000_0007; // FIFO empty + engines idle (preview) + + pub fn board_id_for(slot: ImpactSlot) -> u32 { + match slot { + ImpactSlot::None => 0, + ImpactSlot::Solid => 0x004D_4752, // "MGR" + Solid class tag (ASCII-ish) + ImpactSlot::High => 0x004D_4748, // High IMPACT + ImpactSlot::Max => 0x004D_474D, // Maximum IMPACT + } + } + + pub fn revision_for(slot: ImpactSlot) -> u32 { + match slot { + ImpactSlot::None => 0, + ImpactSlot::Solid => 0x0000_0100, + ImpactSlot::High => 0x0000_0200, + ImpactSlot::Max => 0x0000_0300, + } + } +} + +#[derive(Clone, Copy)] +struct SlotMap { + kind: ImpactSlot, + base: u32, + size: u32, +} + +struct SlotState { + intr_status: u32, + intr_enable: u32, + fifo_depth: u32, +} + +struct MgrasState { + slots: [Option; 3], +} + +#[derive(Clone)] +pub struct Mgras { + map: [SlotMap; 3], + state: std::sync::Arc>, +} + +impl Mgras { + pub fn new(cfg: &ImpactSection) -> Self { + let kinds = [cfg.gfx, cfg.exp0, cfg.exp1]; + let bases = [MGRAS_SLOT_GFX_BASE, MGRAS_SLOT_EXP0_BASE, MGRAS_SLOT_EXP1_BASE]; + let sizes = [MGRAS_SLOT_GFX_SIZE, MGRAS_SLOT_EXP0_SIZE, MGRAS_SLOT_EXP1_SIZE]; + let mut map = [SlotMap { kind: ImpactSlot::None, base: 0, size: 0 }; 3]; + for i in 0..3 { + map[i] = SlotMap { kind: kinds[i], base: bases[i], size: sizes[i] }; + } + let mut slots = [None, None, None]; + for (i, m) in map.iter().enumerate() { + if m.kind != ImpactSlot::None { + slots[i] = Some(SlotState { intr_status: 0, intr_enable: 0, fifo_depth: 0 }); + } + } + Self { + map, + state: std::sync::Arc::new(Mutex::new(MgrasState { slots })), + } + } + + pub fn power_on(&self) { + let mut st = self.state.lock(); + for (i, m) in self.map.iter().enumerate() { + st.slots[i] = if m.kind != ImpactSlot::None { + Some(SlotState { intr_status: 0, intr_enable: 0, fifo_depth: 0 }) + } else { + None + }; + } + } + + pub fn any_slot(&self) -> bool { + self.map.iter().any(|m| m.kind != ImpactSlot::None) + } + + fn locate(&self, addr: u32) -> Option<(usize, u32)> { + for (i, m) in self.map.iter().enumerate() { + if m.kind == ImpactSlot::None { + continue; + } + let reg_base = m.base.wrapping_add(MGRAS_REG_OFF); + if (addr & 0xFFFF_E000) == reg_base { + return Some((i, addr & (MGRAS_REG_SIZE - 1))); + } + // Rest of the slot aperture: reads as 0, writes ignored. + if addr >= m.base && addr < m.base.wrapping_add(m.size) { + return None; + } + } + None + } + + fn read_reg(&self, slot_idx: usize, off: u32) -> u32 { + let kind = self.map[slot_idx].kind; + let st = self.state.lock(); + let Some(s) = &st.slots[slot_idx] else { return 0 }; + match off { + reg::BOARD_ID => reg::board_id_for(kind), + reg::REVISION => reg::revision_for(kind), + reg::STATUS => reg::STATUS_IDLE, + reg::INTR_STATUS => s.intr_status, + reg::INTR_ENABLE => s.intr_enable, + reg::SLOT_ROLE => slot_idx as u32, + _ => 0, + } + } + + fn write_reg(&self, slot_idx: usize, off: u32, val: u32) { + let mut st = self.state.lock(); + let Some(s) = &mut st.slots[slot_idx] else { return }; + match off { + reg::INTR_STATUS => s.intr_status &= !val, + reg::INTR_ENABLE => s.intr_enable = val, + reg::FIFO_WRITE => { + s.fifo_depth = s.fifo_depth.saturating_add(4); + dlog_dev!( + LogModule::Rex3, + "MGRAS slot{}: FIFO write {:08x} (stub, depth={})", + slot_idx, + val, + s.fifo_depth + ); + } + _ => { + dlog_dev!( + LogModule::Rex3, + "MGRAS slot{}: write reg {:04x} = {:08x} (ignored)", + slot_idx, + off, + val + ); + } + } + } +} + +impl Device for Mgras { + fn step(&self, _cycles: u64) {} + fn stop(&self) {} + fn start(&self) {} + fn is_running(&self) -> bool { false } + fn get_clock(&self) -> u64 { 0 } + + fn register_commands(&self) -> Vec<(String, String)> { + vec![ + ("mgras".into(), "IMPACT/MGRAS preview stub (status)".into()), + ("impact".into(), "IMPACT inventory summary (hinv-style preview)".into()), + ] + } + + fn execute_command(&self, cmd: &str, args: &[&str], mut writer: Box) -> Result<(), String> { + if cmd != "mgras" && cmd != "impact" { + return Err(format!("unknown mgras command: {cmd}")); + } + if !args.is_empty() { + return Err(format!("usage: {cmd}")); + } + let st = self.state.lock(); + let names = ["gfx", "exp0", "exp1"]; + if cmd == "impact" { + writeln!(writer, "Graphics inventory (IMPACT preview — driver attach not implemented):").map_err(|e| e.to_string())?; + } + for (i, m) in self.map.iter().enumerate() { + if m.kind == ImpactSlot::None { + if cmd == "impact" { + continue; + } + writeln!(writer, " {}: (empty)", names[i]).map_err(|e| e.to_string())?; + continue; + } + let depth = st.slots[i].as_ref().map(|s| s.fifo_depth).unwrap_or(0); + let label = match m.kind { + ImpactSlot::Solid => "IMPACT Solid", + ImpactSlot::High => "IMPACT High", + ImpactSlot::Max => "IMPACT Maximum", + ImpactSlot::None => unreachable!(), + }; + if cmd == "impact" { + writeln!( + writer, + " Graphics board {}: {} (GIO @ {:#010x})", + i, + label, + m.base.wrapping_add(MGRAS_REG_OFF), + ) + .map_err(|e| e.to_string())?; + } else { + writeln!( + writer, + " {}: {:?} @ {:#010x} fifo_bytes={}", + names[i], + m.kind, + m.base.wrapping_add(MGRAS_REG_OFF), + depth, + ) + .map_err(|e| e.to_string())?; + } + } + if cmd == "impact" && !self.map.iter().any(|m| m.kind != ImpactSlot::None) { + writeln!(writer, " (no IMPACT boards configured in [impact])").map_err(|e| e.to_string())?; + } + Ok(()) + } +} + +impl BusDevice for Mgras { + fn read32(&self, addr: u32) -> BusRead32 { + match self.locate(addr) { + Some((idx, off)) => BusRead32::ok(self.read_reg(idx, off)), + None => BusRead32::ok(0), + } + } + + fn write32(&self, addr: u32, val: u32) -> u32 { + if let Some((idx, off)) = self.locate(addr) { + self.write_reg(idx, off, val); + } + BUS_OK + } + + fn read8(&self, addr: u32) -> BusRead8 { + BusRead8::ok(self.read32(addr & !3).data as u8) + } + fn write8(&self, addr: u32, val: u8) -> u32 { + let shift = (addr & 3) * 8; + let cur = self.read32(addr & !3).data; + self.write32(addr & !3, (cur & !(0xFF << shift)) | ((val as u32) << shift)); + BUS_OK + } + fn read16(&self, addr: u32) -> BusRead16 { + BusRead16::ok(self.read32(addr & !3).data as u16) + } + fn write16(&self, addr: u32, val: u16) -> u32 { + let shift = if (addr & 2) != 0 { 16 } else { 0 }; + let cur = self.read32(addr & !3).data; + self.write32(addr & !3, (cur & !(0xFFFF << shift)) | ((val as u32) << shift)); + BUS_OK + } + fn read64(&self, addr: u32) -> BusRead64 { + let lo = self.read32(addr).data as u64; + let hi = self.read32(addr.wrapping_add(4)).data as u64; + BusRead64::ok((hi << 32) | lo) + } + fn write64(&self, addr: u32, val: u64) -> u32 { + self.write32(addr, val as u32); + self.write32(addr.wrapping_add(4), (val >> 32) as u32); + BUS_OK + } +} + +impl Saveable for Mgras { + fn save_state(&self) -> toml::Value { + let st = self.state.lock(); + let mut slots = toml::map::Map::new(); + let names = ["gfx", "exp0", "exp1"]; + for (i, name) in names.iter().enumerate() { + if self.map[i].kind == ImpactSlot::None { + continue; + } + let Some(s) = st.slots[i].as_ref() else { continue }; + let mut slot_tbl = toml::map::Map::new(); + slot_tbl.insert("intr_status".into(), hex_u32(s.intr_status)); + slot_tbl.insert("intr_enable".into(), hex_u32(s.intr_enable)); + slot_tbl.insert("fifo_depth".into(), toml::Value::Integer(s.fifo_depth as i64)); + slots.insert((*name).into(), toml::Value::Table(slot_tbl)); + } + toml::Value::Table(slots) + } + + fn load_state(&self, v: &toml::Value) -> Result<(), String> { + let Some(tbl) = v.as_table() else { return Ok(()) }; + let names = ["gfx", "exp0", "exp1"]; + let mut st = self.state.lock(); + for (i, name) in names.iter().enumerate() { + let Some(slot_v) = tbl.get(*name) else { continue }; + let Some(s) = st.slots[i].as_mut() else { continue }; + if let Some(x) = get_field(slot_v, "intr_status") { + s.intr_status = toml_u32(x).unwrap_or(s.intr_status); + } + if let Some(x) = get_field(slot_v, "intr_enable") { + s.intr_enable = toml_u32(x).unwrap_or(s.intr_enable); + } + if let Some(x) = get_field(slot_v, "fifo_depth") { + if let Some(n) = x.as_integer() { + s.fifo_depth = n as u32; + } + } + } + Ok(()) + } +} diff --git a/src/mips_exec.rs b/src/mips_exec.rs index a091800..32e7bd0 100644 --- a/src/mips_exec.rs +++ b/src/mips_exec.rs @@ -4824,6 +4824,18 @@ impl MipsCpu { self.idle_profile_on.store(false, Ordering::SeqCst); } + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + pub fn cycles_counter(&self) -> Arc { + Arc::clone(&self.cycles) + } + + pub fn running_flag(&self) -> Arc { + Arc::clone(&self.running) + } + pub fn run_debug_loop(&self, mut count: Option, wait: bool, mut writer: Box) { self.stop(); // Ensure stopped before running @@ -5175,6 +5187,7 @@ impl Device for MipsCpu< let running = self.running.clone(); *self.thread.lock() = Some(thread::Builder::new().name("MIPS-CPU".to_string()).spawn(move || { + crate::thread_affinity::pin_current(crate::thread_affinity::PerfRole::MipsCpu); let mut guard = executor.lock(); #[cfg(feature = "jit")] @@ -5201,19 +5214,7 @@ impl Device for MipsCpu< // iteration, so its state never repeats — we must NOT park it or // boot stalls. The state-repeat test distinguishes the two. #[cfg(feature = "idle-pause")] - let idle_enabled = std::env::var_os("IRIS_NO_IDLE").is_none(); - // Ring of recent architectural-state hashes (PC folded with all GPRs), - // one per idle-suspected batch. A polling/idle loop cycles through a - // small set of states, so a hash repeats within ~the loop period; a - // delay loop's counter makes every state unique, so it never repeats. - #[cfg(feature = "idle-pause")] - const IDLE_RING: usize = 32; - #[cfg(feature = "idle-pause")] - let mut idle_ring = [0u64; IDLE_RING]; - #[cfg(feature = "idle-pause")] - let mut idle_ring_len = 0usize; - #[cfg(feature = "idle-pause")] - let mut idle_ring_pos = 0usize; + let mut idle_state = crate::idle_park::IdleParkState::default(); #[allow(unreachable_code)] while running.load(Ordering::Relaxed) { @@ -5243,107 +5244,15 @@ impl Device for MipsCpu< // Flush local cycle counter to shared atomic once per batch guard.flush_cycles(); - // --- idle detection + in-place park (stop spinning host CPU) --- - // The kernel idle loop is a tight loop with interrupts enabled and - // nothing pending; it exits only on an interrupt. When we detect it, - // park the CPU thread until the next CP0 Compare tick or a device - // interrupt, advancing cp0_count + local_cycles by the REAL elapsed - // time so the wallclock timer and its Compare-write calibration stay - // consistent (advancing only count would spike count_step — see - // docs/idle-pause-work.md §4). We never stop/restart the thread or - // peripherals; we just sleep in place, so the kernel is undisturbed. #[cfg(feature = "idle-pause")] - if idle_enabled { - let ie = guard.core.interrupts_enabled(); - let pending = guard.core.interrupts.load(Ordering::Relaxed) as u32; - let ip = (guard.core.cp0_cause | pending) & crate::mips_core::CAUSE_IP_MASK; - let im = guard.core.cp0_status & crate::mips_core::STATUS_IM_MASK; - let interrupt_ready = (ip & im) != 0; // would be delivered next step - - // `state_repeated` is true only if the current PC+GPR hash - // matches one seen recently — i.e. the loop revisited a state - // (polling), as opposed to a delay loop whose counter makes - // every state unique. - let mut state_repeated = false; - if ie && !interrupt_ready { - // Hash PC + GPRs, but SKIP k0/k1 ($26/$27): those are the - // kernel's exception-handler scratch registers and hold - // leftover junk that differs whenever a timer tick fired - // between iterations — they are not part of the loop's - // real state. (Confirmed: across idle-loop iterations only - // k0/k1 change; a delay loop changes a real reg like v1.) - let mut h = guard.core.pc; - for (i, &g) in guard.core.gpr.iter().enumerate() { - if i == 26 || i == 27 { continue; } - h = h.rotate_left(7) ^ g; - } - if idle_ring[..idle_ring_len].contains(&h) { - // State repeated → still idle. Keep the ring so that - // after a timer tick wakes us the very next batch hash - // matches again and we re-park immediately (otherwise - // we'd re-accumulate the ring every tick, ~5% wasted). - state_repeated = true; - } else { - idle_ring[idle_ring_pos] = h; - idle_ring_pos = (idle_ring_pos + 1) % IDLE_RING; - if idle_ring_len < IDLE_RING { - idle_ring_len += 1; - } - } - } else { - idle_ring_len = 0; - idle_ring_pos = 0; - } - - if state_repeated { - // counts/10ms tick: the CP0 Count hardware rate (independent of - // whether the current tick is the 100 Hz or 1 kHz interval). - let slow_hw = guard.core.compare_delta_slow >> 32; - let cs = guard.core.count_step; // 32.32 counts/instruction - if slow_hw != 0 && cs != 0 { - const SLICE_NS: u64 = 1_000_000; // 1 ms slices → ≤1 ms IRQ latency - const MIN_SLICE_NS: u64 = 50_000; // <50 µs to tick: just fire it - loop { - if !running.load(Ordering::Relaxed) { break; } - let cnt = guard.core.cp0_count; - let cmp = guard.core.cp0_compare; - let diff = cmp.wrapping_sub(cnt); - // high bit set => count already past compare => tick due now - if (diff >> 63) != 0 || (diff >> 32) == 0 { - guard.core.cp0_count = cmp; - guard.core.cp0_cause |= crate::mips_core::CAUSE_IP7; - break; - } - // a device interrupt became ready while we were idle - let pending = guard.core.interrupts.load(Ordering::Relaxed) as u32; - let ip = (guard.core.cp0_cause | pending) & crate::mips_core::CAUSE_IP_MASK; - let im = guard.core.cp0_status & crate::mips_core::STATUS_IM_MASK; - if (ip & im) != 0 { break; } - - let rem_hw = diff >> 32; - let ns_to_tick = (rem_hw as u128 * 10_000_000u128 / slow_hw as u128) as u64; - let slice_ns = ns_to_tick.min(SLICE_NS); - if slice_ns < MIN_SLICE_NS { - guard.core.cp0_count = cmp; - guard.core.cp0_cause |= crate::mips_core::CAUSE_IP7; - break; - } - // Drop the executor lock so the monitor stays responsive - // while we sleep; re-acquire after. - guard.flush_cycles(); - drop(guard); - let t0 = std::time::Instant::now(); - std::thread::sleep(std::time::Duration::from_nanos(slice_ns)); - let elapsed_ns = t0.elapsed().as_nanos() as u64; - guard = executor.lock(); - // Advance guest time by the real elapsed time. - let adv_hw = (elapsed_ns as u128 * slow_hw as u128 / 10_000_000u128) as u64; - guard.core.cp0_count = guard.core.cp0_count.wrapping_add(adv_hw << 32); - let adv_instrs = (((adv_hw as u128) << 32) / cs as u128) as u64; - guard.core.local_cycles = guard.core.local_cycles.wrapping_add(adv_instrs); - } - } + if crate::idle_park::idle_park_enabled() && idle_state.update(&guard.core) { + guard.flush_cycles(); + drop(guard); + { + let mut guard = executor.lock(); + idle_state.park(&mut guard.core, &running); } + guard = executor.lock(); } // --- end idle park --- // --- perf sampling (comment out to disable) --- diff --git a/src/perf_monitor.rs b/src/perf_monitor.rs new file mode 100644 index 0000000..73f8640 --- /dev/null +++ b/src/perf_monitor.rs @@ -0,0 +1,121 @@ +//! Aggregate performance snapshot for the telnet monitor (`perf snapshot`). + +use std::io::Write; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::hal2::Hal2; +use crate::rex3::Rex3; +use crate::traits::Device; + +pub struct PerfMonitor { + cpu_running: Arc, + cycles: Arc, + fasttick: Arc, + rex3: Option>, + hal2: Option>, +} + +impl PerfMonitor { + pub fn new( + cpu_running: Arc, + cycles: Arc, + fasttick: Arc, + rex3: Option>, + hal2: Option>, + ) -> Arc { + Arc::new(Self { cpu_running, cycles, fasttick, rex3, hal2 }) + } +} + +impl Device for PerfMonitor { + fn step(&self, _cycles: u64) {} + fn stop(&self) {} + fn start(&self) {} + fn is_running(&self) -> bool { false } + fn get_clock(&self) -> u64 { 0 } + + fn register_commands(&self) -> Vec<(String, String)> { + vec![( + "perf".to_string(), + "Performance snapshot: perf snapshot".to_string(), + )] + } + + fn execute_command(&self, cmd: &str, args: &[&str], mut writer: Box) -> Result<(), String> { + if cmd != "perf" { + return Err(format!("Unknown command: {}", cmd)); + } + match args.first().copied().unwrap_or("") { + "snapshot" => self.write_snapshot(&mut writer), + _ => Err("Usage: perf snapshot".to_string()), + } + } +} + +impl PerfMonitor { + fn write_snapshot(&self, writer: &mut dyn Write) -> Result<(), String> { + writeln!(writer, "=== IRIS perf snapshot ===").map_err(|e| e.to_string())?; + writeln!( + writer, + "CPU running: {} cycles: {} fastticks: {}", + self.cpu_running.load(Ordering::Relaxed), + self.cycles.load(Ordering::Relaxed), + self.fasttick.load(Ordering::Relaxed), + ).map_err(|e| e.to_string())?; + writeln!(writer, "{}", crate::thread_affinity::status_line()).map_err(|e| e.to_string())?; + + if let Some(rex) = &self.rex3 { + let jit = rex.jit_go_count.load(Ordering::Relaxed); + let interp = rex.interp_go_count.load(Ordering::Relaxed); + let total = jit + interp; + let pct = if total > 0 { jit * 100 / total } else { 0 }; + let diag = rex.diag.load(Ordering::Relaxed); + let (y0, y1) = rex.dirty_y_range(); + writeln!( + writer, + "REX3 GO: {} total JIT {}% gfifo {}/{} simd_fills {}", + total, pct, rex.gfifo.len(), crate::rex3::GFIFO_DEPTH, + rex.simd_fill_rows.load(Ordering::Relaxed), + ).map_err(|e| e.to_string())?; + writeln!( + writer, + "REX3 diag: {:#018x} dirty_y: {}..{} gfxbusy: {}", + diag, + if y0 == u32::MAX { "—".to_string() } else { y0.to_string() }, + y1, + rex.gfxbusy.load(Ordering::Relaxed), + ).map_err(|e| e.to_string())?; + #[cfg(feature = "rex-jit")] + if let Some(ref jit) = rex.rex_jit { + writeln!(writer, "REX3 JIT cache: {} shaders", jit.shader_list().len()).map_err(|e| e.to_string())?; + } + } else { + writeln!(writer, "REX3: not present").map_err(|e| e.to_string())?; + } + + if let Some(hal2) = &self.hal2 { + writeln!( + writer, + "HAL2 underruns: {} codec A: hptimer", + hal2.underrun_count(), + ).map_err(|e| e.to_string())?; + } else { + writeln!(writer, "HAL2: disabled (no_audio)").map_err(|e| e.to_string())?; + } + + #[cfg(feature = "jit")] + { + let jit_on = std::env::var("IRIS_JIT").ok().as_deref() == Some("1"); + let no_stores = std::env::var("IRIS_JIT_NO_STORES").ok().as_deref() == Some("1"); + writeln!( + writer, + "MIPS JIT: {} compile_stores: {}", + if jit_on { "on" } else { "off" }, + if no_stores { "disabled (interpreter stores)" } else { "enabled" }, + ).map_err(|e| e.to_string())?; + } + + Ok(()) + } +} diff --git a/src/physical.rs b/src/physical.rs index 2862e64..ae71b5b 100644 --- a/src/physical.rs +++ b/src/physical.rs @@ -11,6 +11,8 @@ use crate::prom::PromPort; use crate::mc::MemoryController; use crate::hpc3::Hpc3; use crate::rex3::Rex3; +use crate::xz::Xz; +use crate::mgras::Mgras; use crate::vino::Vino; #[cfg(feature = "ultra64")] use crate::ultra64::Ultra64; @@ -194,6 +196,12 @@ pub struct Physical { banks: [Memory; 4], pub rex3: Option>, + /// Second Newport head (dual-head Indigo2 / `graphics.heads = 2`). + pub rex3_head1: Option>, + /// Indy XZ/Elan preview stub (`graphics.board = xz`). + pub xz: Option>, + /// Indigo2 IMPACT/MGRAS preview stub (`[impact]` section). + pub mgras: Option>, #[cfg(feature = "ultra64")] pub ultra64: Option>, pub vino: Vino, @@ -259,6 +267,9 @@ impl Physical { pub fn new( banks: [Memory; 4], rex3: Option>, + rex3_head1: Option>, + xz: Option>, + mgras: Option>, #[cfg(feature = "ultra64")] ultra64: Option>, vino: Vino, @@ -294,6 +305,9 @@ impl Physical { Self { banks, rex3, + rex3_head1, + xz, + mgras, #[cfg(feature = "ultra64")] ultra64, vino, @@ -324,6 +338,10 @@ impl Physical { let cpu_err_ptr: *const dyn BusDevice = &self.cpu_bus_error; let gio_err_ptr: *const dyn BusDevice = &self.gio_bus_error; let rex3_ptr: Option<*const dyn BusDevice> = self.rex3.as_deref().map(|r| r as *const dyn BusDevice); + let rex3_head1_ptr: Option<*const dyn BusDevice> = + self.rex3_head1.as_deref().map(|r| r as *const dyn BusDevice); + let xz_ptr: Option<*const dyn BusDevice> = self.xz.as_deref().map(|x| x as *const dyn BusDevice); + let mgras_ptr: Option<*const dyn BusDevice> = self.mgras.as_deref().map(|m| m as *const dyn BusDevice); #[cfg(feature = "ultra64")] let ultra64_ptr: Option<*const dyn BusDevice> = self.ultra64.as_deref().map(|u| u as *const dyn BusDevice); let vino_ptr: *const dyn BusDevice = &self.vino; @@ -382,9 +400,32 @@ impl Physical { for i in (NEWPORT_BASE >> 16)..((NEWPORT_END - 1) >> 16) + 1 { self.device_map[i as usize] = rex3_ptr; } + } else if let Some(xz_ptr) = xz_ptr { + // Indy XZ/Elan preview: same gfx slot, HQ2 register stub in `src/xz.rs`. + for i in (NEWPORT_BASE >> 16)..((NEWPORT_END - 1) >> 16) + 1 { + self.device_map[i as usize] = xz_ptr; + } + } else if let Some(mgras_ptr) = mgras_ptr { + // Indigo2 IMPACT preview: MGRAS stub spans gfx + populated expansion slots. + for i in (NEWPORT_BASE >> 16)..((NEWPORT_END - 1) >> 16) + 1 { + self.device_map[i as usize] = mgras_ptr; + } + for i in (GIO_SLOT0_BASE >> 16)..((GIO_SLOT0_END - 1) >> 16) + 1 { + self.device_map[i as usize] = mgras_ptr; + } + for i in (GIO_SLOT1_BASE >> 16)..((GIO_SLOT1_END - 1) >> 16) + 1 { + self.device_map[i as usize] = mgras_ptr; + } } // else: GIO timeout from layer 2 already covers the Newport slot + // Second Newport head at GIO expansion slot 1 (dual-head). + if let Some(h1_ptr) = rex3_head1_ptr { + for i in (GIO_SLOT1_BASE >> 16)..((GIO_SLOT1_END - 1) >> 16) + 1 { + self.device_map[i as usize] = h1_ptr; + } + } + // GIO expansion slot 0 (0x1F400000–0x1F5FFFFF): N64 dev board if enabled #[cfg(feature = "ultra64")] if let Some(u64_ptr) = ultra64_ptr { @@ -398,7 +439,7 @@ impl Physical { self.device_map[i as usize] = u64_ptr; } } - // GIO expansion slot 1 (0x1F600000–0x1F9FFFFF) — no device, GIO timeout remains + // GIO expansion slot 1 — second Newport when rex3_head1 absent: GIO timeout remains // Map MC registers (128KB at 0x1FA00000) for i in (MC_BASE >> 16)..((MC_END - 1) >> 16) + 1 { diff --git a/src/platform_profile_tests.rs b/src/platform_profile_tests.rs new file mode 100644 index 0000000..f73e2e5 --- /dev/null +++ b/src/platform_profile_tests.rs @@ -0,0 +1,209 @@ +//! Headless acceptance tests for IP22/IP24 profile wiring, XZ, and IMPACT stubs. +//! +//! These run in `cargo test --lib` without booting the guest. When +//! `irix-install/scsi1.raw` is present, use `iris-indigo2-smoke-ci.toml` for +//! live boot checks (monitor on 127.0.0.1:8888; CI serial is channel B / IRIX). + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use parking_lot::Mutex; + + use crate::config::{ + GraphicsBoard, ImpactSection, ImpactSlot, MachineConfig, MachineProfile, + }; + use crate::eeprom_93c56::Eeprom93c56; + use crate::ioc::{Ioc, IOC_BASE, IOC_SYS_ID, l1_regs, IOC_INT3_L1_STAT}; + use crate::mgras::{self, reg as mgras_reg, Mgras, MGRAS_REG_OFF, MGRAS_SLOT_GFX_BASE}; + use crate::traits::{BusDevice, Saveable}; + use crate::xz::{self, reg as xz_reg, Xz, XZ_REG_BASE}; + + fn minimal_cfg() -> MachineConfig { + let mut cfg = MachineConfig::default(); + cfg.scsi.clear(); + cfg + } + + #[test] + fn irix_install_scsi_disk_present() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/irix-install/scsi1.raw"); + assert!( + std::path::Path::new(path).exists(), + "irix-install/scsi1.raw — IRIX root disk for smoke/CI configs" + ); + } + + #[test] + fn indigo2_smoke_ci_toml_parses_and_validates() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/irix-install/iris-indigo2-smoke-ci.toml" + ); + let text = std::fs::read_to_string(path).expect("smoke ci toml"); + let cfg: MachineConfig = toml::from_str(&text).expect("parse smoke toml"); + cfg.validate().expect("smoke config validates"); + assert_eq!(cfg.machine.profile, MachineProfile::Indigo2Ip22); + assert!(cfg.headless); + assert!(cfg.ci); + } + + #[test] + fn impact_solid_on_indigo2_validates() { + let mut cfg = minimal_cfg(); + cfg.machine.profile = MachineProfile::Indigo2Ip22; + cfg.impact = ImpactSection { + gfx: ImpactSlot::Solid, + exp0: ImpactSlot::None, + exp1: ImpactSlot::None, + }; + cfg.validate().expect("Solid IMPACT on Indigo2"); + } + + #[test] + fn impact_on_indy_rejected() { + let mut cfg = minimal_cfg(); + cfg.machine.profile = MachineProfile::IndyIp24; + cfg.impact.gfx = ImpactSlot::Solid; + let err = cfg.validate().unwrap_err(); + assert!( + err.contains("indigo2_ip22"), + "expected Indigo2-only guard, got: {err}" + ); + } + + #[test] + fn xz_on_indy_validates() { + let mut cfg = minimal_cfg(); + cfg.machine.profile = MachineProfile::IndyIp24; + cfg.graphics.board = GraphicsBoard::Xz; + cfg.graphics.heads = 1; + cfg.validate().expect("XZ board on Indy"); + } + + #[test] + fn xz_on_indigo2_rejected() { + let mut cfg = minimal_cfg(); + cfg.machine.profile = MachineProfile::Indigo2Ip22; + cfg.graphics.board = GraphicsBoard::Xz; + let err = cfg.validate().unwrap_err(); + assert!(err.contains("indy_ip24"), "got: {err}"); + } + + #[test] + fn dual_head_indigo2_validates() { + let mut cfg = minimal_cfg(); + cfg.machine.profile = MachineProfile::Indigo2Ip22; + cfg.graphics.heads = 2; + cfg.validate().expect("dual-head Indigo2"); + } + + #[test] + fn ioc_sys_id_matches_profile() { + let sys = IOC_BASE + IOC_SYS_ID; + let indy = Ioc::new_ci(true); + assert_eq!(indy.read32(sys).data, 0x26, "Indy guinness sys_id"); + + let fullhouse = Ioc::new_ci(false); + assert_eq!( + fullhouse.read32(sys).data, + 0x11, + "Indigo2 fullhouse sys_id" + ); + } + + #[test] + fn fullhouse_vblank_routes_sg_retrace_to_l1() { + let ioc = Ioc::new_ci(false); + ioc.set_interrupt(crate::ioc::IocInterrupt::VerticalRetrace, true); + let l1 = ioc.read32(IOC_BASE + IOC_INT3_L1_STAT).data as u8; + assert_ne!(l1 & l1_regs::VERTICAL_RETRACE, 0, "SG retrace should assert L1 vblank"); + ioc.set_interrupt(crate::ioc::IocInterrupt::VerticalRetrace, false); + let l1 = ioc.read32(IOC_BASE + IOC_INT3_L1_STAT).data as u8; + assert_eq!(l1 & l1_regs::VERTICAL_RETRACE, 0); + } + + #[test] + fn fullhouse_gfx_drain_does_not_assert_l1_vblank() { + let ioc = Ioc::new_ci(false); + ioc.set_interrupt(crate::ioc::IocInterrupt::GfxDrain0, true); + let l1 = ioc.read32(IOC_BASE + IOC_INT3_L1_STAT).data as u8; + assert_eq!( + l1 & l1_regs::VERTICAL_RETRACE, + 0, + "MAP GFX_DRAIN0 must not masquerade as vertical retrace" + ); + } + + #[test] + fn ioc_fullhouse_gc_select_read_write() { + let ioc = Ioc::new_ci(false); + let gc = IOC_BASE + crate::ioc::IOC_GC_SELECT; + assert_eq!(ioc.write32(gc, 0x0F), crate::traits::BUS_OK); + assert_eq!(ioc.read32(gc).data, 0x0F, "fullhouse gc_select round-trip"); + } + + #[test] + fn xz_board_id_probe() { + let xz = Xz::new(); + let id = xz.read32(XZ_REG_BASE + xz_reg::BOARD_ID).data; + assert_eq!(id, xz_reg::BOARD_ID_VAL); + let rev = xz.read32(XZ_REG_BASE + xz_reg::REVISION).data; + assert_eq!(rev, xz_reg::REVISION_VAL); + let status = xz.read32(XZ_REG_BASE + xz_reg::STATUS).data; + assert_eq!(status, xz_reg::STATUS_RESET_VAL); + } + + #[test] + fn xz_fifo_write_tracks_depth() { + let xz = Xz::new(); + assert_eq!(xz.write32(XZ_REG_BASE + xz_reg::FIFO_WRITE, 0x1234_5678), crate::traits::BUS_OK); + let saved = xz.save_state(); + let depth = saved + .get("fifo_depth") + .and_then(|v| v.as_integer()) + .expect("fifo_depth in save_state"); + assert_eq!(depth, 4); + } + + #[test] + fn mgras_solid_impact_board_id() { + let cfg = ImpactSection { + gfx: ImpactSlot::Solid, + exp0: ImpactSlot::None, + exp1: ImpactSlot::None, + }; + let m = Mgras::new(&cfg); + let addr = MGRAS_SLOT_GFX_BASE + MGRAS_REG_OFF + mgras_reg::BOARD_ID; + let id = m.read32(addr).data; + assert_eq!(id, mgras_reg::board_id_for(ImpactSlot::Solid)); + } + + #[test] + fn mgras_maximum_three_slot_map() { + let cfg = ImpactSection { + gfx: ImpactSlot::Solid, + exp0: ImpactSlot::High, + exp1: ImpactSlot::Max, + }; + let m = Mgras::new(&cfg); + assert!(m.any_slot()); + for (slot, kind) in [ + (mgras::MGRAS_SLOT_GFX_BASE, ImpactSlot::Solid), + (mgras::MGRAS_SLOT_EXP0_BASE, ImpactSlot::High), + (mgras::MGRAS_SLOT_EXP1_BASE, ImpactSlot::Max), + ] { + let addr = slot + MGRAS_REG_OFF + mgras_reg::BOARD_ID; + assert_eq!(m.read32(addr).data, mgras_reg::board_id_for(kind)); + } + } + + #[test] + fn profile_eeprom_independent_of_mc_sysid() { + // MC SYSID is covered in mc.rs; here we sanity-check both profiles share + // the same EEPROM path convention (no compile-time indigo2 gate). + let eeprom = Arc::new(Mutex::new(Eeprom93c56::new())); + let _indy_mc = crate::mc::MemoryController::new(eeprom.clone(), true, [128, 128, 0, 0]); + let _i2_mc = crate::mc::MemoryController::new(eeprom, false, [128, 128, 0, 0]); + } +} diff --git a/src/rex3.rs b/src/rex3.rs index 646daab..6a8940d 100644 --- a/src/rex3.rs +++ b/src/rex3.rs @@ -30,6 +30,8 @@ pub trait Renderer: Send { sbtex: &mut crate::disp::StatusBarTexture, stats: &crate::disp::BarStats, need_readback: bool, + live_fb_rgb: Option<&[u32]>, + live_fb_aux: Option<&[u32]>, ); fn resize(&mut self, _width: usize, _height: usize) {} @@ -1049,6 +1051,8 @@ pub struct Rex3 { pub cmap1: Mutex, pub bt445: Mutex, clock: AtomicU64, + /// REX3 refresh thread iterations (~display vsync cadence). + refresh_frames: AtomicU64, running: AtomicBool, pub gfxbusy: Arc, pub processor_thread: Mutex>>, @@ -1068,8 +1072,18 @@ pub struct Rex3 { /// re-uploading the whole framebuffer at 60 Hz on a static screen. Starts /// true so the first frame always renders. fb_dirty: AtomicBool, + /// Tile dirty tracking: min/max X/Y touched since last refresh (Phase 3). + dirty_x_min: AtomicU32, + dirty_x_max: AtomicU32, + dirty_y_min: AtomicU32, + dirty_y_max: AtomicU32, + /// Rows filled via rex3_simd fastclear path (monitor `perf snapshot`). + pub simd_fill_rows: AtomicU64, pub screen: Arc>, pub vblank_cb: Mutex>>, + pub fifo_full_cb: Mutex>>, + pub graphics_cb: Mutex>>, + pub gfx_drain_cb: Mutex>>, /// Incremented each time an XMAP mode table entry is written (buf_sel flip signal). /// Payload of GFIFO_DISP_SYNC pushed to the GFIFO on each such write. pub xmap_fence: AtomicU32, @@ -1190,6 +1204,7 @@ impl Rex3 { cmap1: Mutex::new(Cmap::new(1)), bt445: Mutex::new(Bt445::new()), clock: AtomicU64::new(0), + refresh_frames: AtomicU64::new(0), running: AtomicBool::new(false), gfxbusy: Arc::new(AtomicBool::new(false)), processor_thread: Mutex::new(None), @@ -1199,8 +1214,16 @@ impl Rex3 { #[cfg(feature = "idle-pause")] processor_unparker: std::sync::OnceLock::new(), fb_dirty: AtomicBool::new(true), + dirty_x_min: AtomicU32::new(u32::MAX), + dirty_x_max: AtomicU32::new(0), + dirty_y_min: AtomicU32::new(u32::MAX), + dirty_y_max: AtomicU32::new(0), + simd_fill_rows: AtomicU64::new(0), screen, vblank_cb: Mutex::new(None), + fifo_full_cb: Mutex::new(None), + graphics_cb: Mutex::new(None), + gfx_drain_cb: Mutex::new(None), xmap_fence: AtomicU32::new(0), gfifo_fence: AtomicU32::new(0), debug: Arc::new(AtomicBool::new(false)), @@ -1296,6 +1319,81 @@ impl Rex3 { *self.vblank_cb.lock() = Some(cb); } + pub fn set_fifo_full_callback(&self, cb: Arc) { + *self.fifo_full_cb.lock() = Some(cb); + } + + pub fn set_graphics_callback(&self, cb: Arc) { + *self.graphics_cb.lock() = Some(cb); + } + + pub fn set_gfx_drain_callback(&self, cb: Arc) { + *self.gfx_drain_cb.lock() = Some(cb); + } + + /// Program VC2 with a host-side Newport timing preset (see `[graphics] resolution`). + pub fn apply_display_resolution(&self, mode: crate::vc2_timings::NewportResolution) { + if mode.is_guest() { + return; + } + { + let mut vc2 = self.vc2.lock(); + crate::vc2_timings::apply_newport_resolution(&mut vc2, mode); + } + // Idle background until the guest paints (compositor uses host-only + // direct-colour fallback while xmap is still zero — see CaptureRenderer). + if let Some((w, h)) = mode.visible_size() { + const FILL: u32 = 0x0060_0000; // dark blue, Newport BGR (B<<16|G<<8|R) + let w = w as usize; + let h = h as usize; + unsafe { + let fb = &mut *self.fb_rgb.get(); + for y in 0..h.min(1024) { + let row = y * 2048; + fb[row..row + w.min(2048)].fill(FILL); + } + } + } + self.fb_dirty.store(true, Ordering::Relaxed); + } + + #[inline] + fn gfifo_hw_level(pending: usize) -> u32 { + if pending == 0 { + 0 + } else { + (pending.saturating_sub(GFIFO_DEPTH - GFIFO_HW_DEPTH) as u32).max(1) + } + } + + /// Recompute GFIFO threshold interrupts and drive IOC callback lines. + fn update_gfifo_irqs(&self) { + let pending = self.gfifo.len(); + let level = Self::gfifo_hw_level(pending); + let cfg = self.config.config.load(Ordering::Relaxed); + let threshold = (cfg >> CONFIG_GFIFODEPTH_SHIFT) & 0x1F; + let above_int = cfg & CONFIG_GFIFOABOVEINT != 0; + + let above = above_int && level >= threshold; + if above { + self.config.status.fetch_or(STATUS_GFIFO_INT, Ordering::Relaxed); + } else { + self.config.status.fetch_and(!STATUS_GFIFO_INT, Ordering::Relaxed); + } + + if let Some(cb) = self.fifo_full_cb.lock().clone() { + cb(above); + } + if let Some(cb) = self.gfx_drain_cb.lock().clone() { + // Drain interrupt: FIFO has dropped below the high-water threshold. + cb(above_int && !above); + } + let gfx_idle = !self.gfxbusy.load(Ordering::Acquire) && pending == 0; + if let Some(cb) = self.graphics_cb.lock().clone() { + cb(gfx_idle); + } + } + #[cfg(feature = "developer")] pub fn set_count_step_atomic(&self, arc: Arc) { *self.count_step_atomic.lock() = arc; @@ -1463,6 +1561,13 @@ impl Rex3 { } fn draw_block(&self, ctx: &mut Rex3Context) { + if crate::rex3_simd::try_fastclear_block(self, ctx) { + return; + } + if crate::rex3_simd::try_src_block_rgb(self, ctx) { + return; + } + let _w = (ctx.xend - ctx.xstart).abs(); let _h = (ctx.yend - ctx.ystart).abs(); @@ -1568,6 +1673,9 @@ impl Rex3 { } fn draw_span(&self, ctx: &mut Rex3Context) { + if crate::rex3_simd::try_src_span_rgb(self, ctx) { + return; + } if ctx.drawmode0.lronly() && (ctx.bresoctinc1.octant() & OCTANT_XDEC) != 0{ return; } @@ -1653,7 +1761,114 @@ impl Rex3 { } } + /// Fractional d correction for F_LINE/A_LINE from 21.11 endpoint sub-pixel position. + /// Fractional nibble is bits [10:7] of the 21.11 coordinate (16.4(7) layout). + fn fline_apply_fract( + ctx: &Rex3Context, + d: &mut i32, + x: &mut i32, + y: &mut i32, + incrx2: i32, + incry2: i32, + y_major: bool, + ) { + let octant = ctx.bresoctinc1.octant() & 7; + let x1p = ctx.xstart >> 11; + let y1p = ctx.ystart >> 11; + let x2p = ctx.xend >> 11; + let y2p = ctx.yend >> 11; + let mut dx = (x1p - x2p).abs(); + let mut dy = (y1p - y2p).abs(); + let mut xf = ((ctx.xstart >> 7) & 0xF) as i32; + let mut yf = ((ctx.ystart >> 7) & 0xF) as i32; + + match octant { + 1 => { + std::mem::swap(&mut xf, &mut yf); + std::mem::swap(&mut dx, &mut dy); + } + 3 => { + xf = 0x10 - xf; + std::mem::swap(&mut xf, &mut yf); + std::mem::swap(&mut dx, &mut dy); + } + 7 => { xf = 0x10 - xf; } + 6 => { + xf = 0x10 - xf; + yf = 0x10 - yf; + } + 2 => { + let t = 0x10 - xf; + xf = 0x10 - yf; + yf = t; + std::mem::swap(&mut dx, &mut dy); + } + 0 => { + let t = 0x10 - yf; + yf = xf; + xf = t; + std::mem::swap(&mut dx, &mut dy); + } + 4 => { yf = 0x10 - yf; } + _ => {} + } + + *d += 2 * (((dx * yf) >> 4) - ((dy * xf) >> 4)); + let major_delta = if y_major { dy } else { dx }; + let e = *d - 2 * major_delta; + if e > 0 { + *d = e; + let x_major = !y_major; + if x_major { + *y -= incry2; + } else { + *x += incrx2; + } + } + } + fn draw_iline(&self, ctx: &mut Rex3Context) { + self.draw_line_bresenham(ctx, false, false, false); + } + + fn draw_fline(&self, ctx: &mut Rex3Context) { + self.draw_line_bresenham(ctx, true, false, false); + } + + fn draw_aline(&self, ctx: &mut Rex3Context) { + let mut extra_skip_first = false; + let mut extra_skip_last = false; + if ctx.drawmode0.endptfilter() { + // Basic endpoint filter: consult AWEIGHT LUT for sub-pixel coverage. + let xsf = (ctx.xstart >> 7) & 0xF; + let ysf = (ctx.ystart >> 7) & 0xF; + let xef = (ctx.xend >> 7) & 0xF; + let yef = (ctx.yend >> 7) & 0xF; + if xsf != 0 || ysf != 0 { + let wi = ((xsf + ysf) as usize).min(15); + let w = (ctx.aweight0 >> (wi * 4)) & 0xF; + if w == 0 { + extra_skip_first = true; + } + } + if xef != 0 || yef != 0 { + let wi = ((xef + yef) as usize).min(15); + let w = (ctx.aweight1 >> (wi * 4)) & 0xF; + if w == 0 { + extra_skip_last = true; + } + } + } + self.draw_line_bresenham(ctx, true, extra_skip_first, extra_skip_last); + } + + fn draw_line_bresenham( + &self, + ctx: &mut Rex3Context, + fract: bool, + extra_skip_first: bool, + extra_skip_last: bool, + ) { // Bresenham octant table (aped from MAME do_iline s_bresenham_infos). // Fields: (incrx1, incrx2, incry1, incry2, y_major) // MAME applies y as `y -= incry`, so positive incry moves y in the negative direction. @@ -1691,16 +1906,25 @@ impl Rex3 { if raw & (1 << 26) != 0 { (raw | 0xF800_0000) as i32 } else { raw as i32 } }; + if fract && ctx.drawmode0.dosetup() { + Self::fline_apply_fract(ctx, &mut d, &mut x, &mut y, incrx2, incry2, y_major); + } + // pixel_count = major_axis_length + 1 (both endpoints inclusive). - let major = if y_major { (y2 - y).abs() } else { (x2 - x).abs() }; + // max(|dx|,|dy|): continuation GOs (dosetup clear) must still walk the full + // segment when persisted octant y_major disagrees with start→end (e.g. a + // degenerate setup GO followed by a horizontal stipple continuation). + let adx = (x2 - x).abs(); + let ady = (y2 - y).abs(); + let major = adx.max(ady); let mut pixel_count = major + 1; if ctx.drawmode0.length32() && pixel_count > 32 { pixel_count = 32; } let iterate_one = !ctx.drawmode0.stoponx() && !ctx.drawmode0.stopony(); - let mut skip_first = ctx.drawmode0.skipfirst(); - let mut skip_last = ctx.drawmode0.skiplast(); + let mut skip_first = ctx.drawmode0.skipfirst() || extra_skip_first; + let mut skip_last = ctx.drawmode0.skiplast() || extra_skip_last; if iterate_one { pixel_count = 1; skip_first = false; @@ -1710,6 +1934,7 @@ impl Rex3 { let proc_fn = unsafe { *self.px_proc.get() }; let shade_fn = unsafe { *self.px_shade.get() }; let pattern_fn = unsafe { *self.px_pattern.get() }; + let lsadvlast = ctx.drawmode0.lsadvlast(); macro_rules! bres_step { () => { @@ -1733,7 +1958,9 @@ impl Rex3 { } shade_fn(ctx); - pattern_fn(ctx); + if !is_last || lsadvlast { + pattern_fn(ctx); + } // On the last pixel of a full-line draw, verify Bresenham landed on x2,y2. // Skip in step mode (pixel_count==1) where we draw only one intermediate pixel. @@ -1837,14 +2064,15 @@ impl Rex3 { // LSRCOUNT (down counter, 0..LSREPEAT-1): decremented each pixel. // When LSRCOUNT == 0 after decrement → advance pat_bit, reload LSRCOUNT = LSREPEAT-1. // LSLENGTH (4 bits): pattern length = lslength + 17 (range 17..32). - // pat_bit wraps: when it would go below (32 - length), reset to 31. + // At pattern end the hardware recirculates by rotating LSPATTERN left (ROL). // LSREPEAT==0 is treated as 1 (no-repeat is the degenerate case). fn iterate_pattern_noop(_ctx: &mut Rex3Context) {} #[inline(always)] fn advance_zpat(ctx: &mut Rex3Context) { - ctx.zpat_bit = ctx.zpat_bit.wrapping_sub(1) & 31; + // Hardware recirculates MSB-first: rotate left through the 32-bit pattern. + ctx.zpat_bit = ctx.zpat_bit.wrapping_add(1) & 31; } #[inline(always)] @@ -1857,9 +2085,10 @@ impl Rex3 { let length = ctx.lsmode.lslength() as u8 + 17; // 17..=32 let wrap_point = 32u8.saturating_sub(length); // bit index of pattern end if ctx.pat_bit == wrap_point { - ctx.pat_bit = 31; // recirculate + // Recirculate: rotate pattern left, keep cursor at wrap_point. + ctx.lspattern = ctx.lspattern.rotate_left(1); } else { - ctx.pat_bit = ctx.pat_bit.wrapping_sub(1) & 31; + ctx.pat_bit = ctx.pat_bit.wrapping_add(1) & 31; } } else { ctx.lsmode.set_lsrcount(ctx.lsmode.lsrcount() - 1); @@ -2301,7 +2530,7 @@ impl Rex3 { } /// Replicate colorvram to fill plane-depth slots, matching MAME get_default_color() with fastclear=1. - fn fastclear_color(ctx: &Rex3Context) -> u32 { + pub(crate) fn fastclear_color(ctx: &Rex3Context) -> u32 { let v = ctx.colorvram; match ctx.drawmode1.drawdepth() { 0 => { let c = v & 0xf; c | (c << 4) | (c << 8) | (c << 16) } @@ -2578,10 +2807,10 @@ impl Rex3 { dlog_dev!(LogModule::Dcb, "DCB Write: Val {:08x} Mode {:08x} (Addr {} CRS {} DW {})", val, dcb.dcbmode, addr, dcb.crs(), data_width); // DCBMODE bit 28 "Swap Byte Ordering": swap within the data width. - // DW_3 is ambiguous — skipped for now. if (dcb.dcbmode & DCBMODE_SWAPENDIAN) != 0 { val = match data_width { DCBMODE_DATAWIDTH_2 => ((val & 0x00FF00FF) << 8) | ((val >> 8) & 0x00FF00FF), + DCBMODE_DATAWIDTH_3 => ((val & 0x0000FF) << 16) | (val & 0x00FF00) | ((val >> 16) & 0x0000FF), DCBMODE_DATAWIDTH_4 => val.swap_bytes(), _ => val, }; @@ -2915,6 +3144,7 @@ impl Rex3 { }); } self.gfifo.push(addr, val); + self.update_gfifo_irqs(); // Wake the consumer if it parked on an empty fifo (idle desktop). Cheap // on the hot path: a relaxed-ish load that is false whenever the // processor is actively draining. @@ -2936,7 +3166,67 @@ impl Rex3 { } } + pub fn dirty_y_range(&self) -> (u32, u32) { + ( + self.dirty_y_min.load(Ordering::Relaxed), + self.dirty_y_max.load(Ordering::Relaxed), + ) + } + + pub fn dirty_x_range(&self) -> (u32, u32) { + ( + self.dirty_x_min.load(Ordering::Relaxed), + self.dirty_x_max.load(Ordering::Relaxed), + ) + } + + pub(crate) fn note_fb_x(&self, x: i32) { + if x < 0 || x >= REX3_SCREEN_WIDTH as i32 { + return; + } + let x = x as u32; + let mut min = self.dirty_x_min.load(Ordering::Relaxed); + while x < min { + match self.dirty_x_min.compare_exchange_weak(min, x, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(v) => min = v, + } + } + let mut max = self.dirty_x_max.load(Ordering::Relaxed); + while x > max { + match self.dirty_x_max.compare_exchange_weak(max, x, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(v) => max = v, + } + } + } + + pub(crate) fn note_fb_y(&self, y: i32) { + if y < 0 || y >= REX3_SCREEN_HEIGHT as i32 { + return; + } + let y = y as u32; + let mut min = self.dirty_y_min.load(Ordering::Relaxed); + while y < min { + match self.dirty_y_min.compare_exchange_weak(min, y, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(v) => min = v, + } + } + let mut max = self.dirty_y_max.load(Ordering::Relaxed); + while y > max { + match self.dirty_y_max.compare_exchange_weak(max, y, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(v) => max = v, + } + } + } + pub fn calculate_fb_address(&self, x: i32, y: i32, ctx: &Rex3Context, is_write: bool) -> Option { + if is_write { + self.note_fb_x(x); + self.note_fb_y(y); + } // 1. XYOFFSET (Draw only, not SCR2SCR source) let opcode = ctx.drawmode0.opcode(); let is_scr2scr = opcode == DRAWMODE0_OPCODE_SCR2SCR; @@ -3078,8 +3368,9 @@ impl Rex3 { // (context registers + FB) only after both process_register and execute_go // have completed. self.gfifo.consume(); + self.update_gfifo_irqs(); - if exit { + if exit { self.gfxbusy.store(false, Ordering::Release); self.gfifo.flush_head(); break; @@ -3088,6 +3379,7 @@ impl Rex3 { if is_busy { self.gfxbusy.store(false, Ordering::Release); is_busy = false; + self.update_gfifo_irqs(); } self.gfifo.flush_head(); // Nothing in the ring — back off. Spin-hint/yield while a burst @@ -3122,22 +3414,41 @@ impl Rex3 { let ctx = unsafe { &mut *self.context.get() }; let opcode = ctx.drawmode0.opcode(); - // FIXME: unclear whether pat_bit/zpat_bit should reset to 31 on every GO or carry - // over across primitives. GL stippled line loops likely want continuity between - // segments; resetting here would break that. Need a real test app on hardware to - // verify. For now reset to 31 - ctx.pat_bit = 31; - ctx.zpat_bit = 31; + // Pattern bit positions reset only on DOSETUP (new primitive). Connected + // stippled line segments keep pat_bit across GO via LSSAVE/LSRESTORE. + if ctx.drawmode0.dosetup() { + ctx.pat_bit = 31; + ctx.zpat_bit = 31; + } // lsrcount is live state inside the lsmode register — do NOT reset it here. // The ARCS diag writes a pattern to lsmode, issues a GO, then reads it back and // expects the value unchanged. Resetting lsrcount on GO would corrupt the readback. // GL manages lsrcount explicitly via LSSAVE/LSRESTORE for connected stippled lines. - // FIXME: also unclear whether pattern advance is ROL (rotate-left, hardware - // recirculation) or ROR as currently implemented. Suspect ROL based on - // "recirculating iterator" language in the spec. Verify with test app on real Indy. if ctx.drawmode0.dosetup() { self.setup(ctx); + } else { + // Continuation GO: re-derive Bresenham when the segment axis disagrees with + // the persisted octant (e.g. degenerate setup point, then horizontal cont). + let adrmode = ctx.drawmode0.adrmode() << 2; + let is_line = adrmode == DRAWMODE0_ADRMODE_I_LINE + || adrmode == DRAWMODE0_ADRMODE_F_LINE + || adrmode == DRAWMODE0_ADRMODE_A_LINE; + if is_line { + let xs = ctx.xstart >> 11; + let ys = ctx.ystart >> 11; + let xe = ctx.xend >> 11; + let ye = ctx.yend >> 11; + let adx = (xe - xs).abs(); + let ady = (ye - ys).abs(); + if adx != ady { + let seg_x_major = adx > ady; + let oct_x_major = (ctx.bresoctinc1.octant() & OCTANT_XMAJOR) != 0; + if seg_x_major != oct_x_major { + self.setup(ctx); + } + } + } } if devlog_is_active(LogModule::Rex3) { @@ -3287,11 +3598,12 @@ impl Rex3 { if opcode != DRAWMODE0_OPCODE_NOOP { let adrmode = ctx.drawmode0.adrmode() << 2; - if adrmode == DRAWMODE0_ADRMODE_I_LINE - || adrmode == DRAWMODE0_ADRMODE_F_LINE - || adrmode == DRAWMODE0_ADRMODE_A_LINE - { + if adrmode == DRAWMODE0_ADRMODE_I_LINE { self.draw_iline(ctx); + } else if adrmode == DRAWMODE0_ADRMODE_F_LINE { + self.draw_fline(ctx); + } else if adrmode == DRAWMODE0_ADRMODE_A_LINE { + self.draw_aline(ctx); } else if adrmode == DRAWMODE0_ADRMODE_BLOCK { self.log_block(ctx, opcode); self.draw_block(ctx); @@ -3422,9 +3734,11 @@ impl Rex3 { // Idle-skip bookkeeping (see the should_render gate below). let mut last_topscan: usize = usize::MAX; let mut frames_since_render: u32 = u32::MAX; + let mut full_presents: u32 = 0; while self.running.load(Ordering::Relaxed) { let start = std::time::Instant::now(); + self.refresh_frames.fetch_add(1, Ordering::Relaxed); // Poll and clear activity bits; preserve persistent LED bits. let bar_stats = crate::disp::BarStats { @@ -3432,6 +3746,7 @@ impl Rex3 { hb: self.heartbeat.fetch_and(Self::HB_PERSISTENT, Ordering::Relaxed), cycles: self.cycles.load(Ordering::Relaxed), fasttick: self.fasttick_count.load(Ordering::Relaxed), + refresh_frames: self.refresh_frames.load(Ordering::Relaxed), #[cfg(feature = "developer")] decoded_delta: self.decoded_count.swap(0, Ordering::Relaxed), #[cfg(not(feature = "developer"))] @@ -3464,12 +3779,19 @@ impl Rex3 { { let target = self.xmap_fence.load(Ordering::Acquire); let backoff = crossbeam_utils::Backoff::new(); + let fence_wait_start = std::time::Instant::now(); loop { let current = self.gfifo_fence.load(Ordering::Acquire); // Wrapping comparison: current >= target if current.wrapping_sub(target) < 0x8000_0000 { break; } + // Don't stall the refresh thread forever if the GFIFO consumer + // is behind (e.g. guest reprogramming XMAP while idle). + if fence_wait_start.elapsed() > std::time::Duration::from_millis(50) { + self.gfifo_fence.store(target, Ordering::Release); + break; + } backoff.snooze(); } } @@ -3501,7 +3823,8 @@ impl Rex3 { let dbg_overlay = self.draw_debug.load(Ordering::Relaxed) || self.show_cmap.load(Ordering::Relaxed) || self.show_disp_debug.load(Ordering::Relaxed); - let should_render = self.fb_dirty.swap(false, Ordering::Acquire) + let fb_was_dirty = self.fb_dirty.swap(false, Ordering::Acquire); + let should_render = fb_was_dirty || palette_dirty || dbg_overlay || topscan != last_topscan @@ -3514,6 +3837,38 @@ impl Rex3 { self.diag.fetch_or(Self::DIAG_LOCK_SCREEN, Ordering::Relaxed); let mut screen = self.screen.lock(); screen.topscan = topscan; + screen.status_bar_only = full_presents > 0 + && !fb_was_dirty + && !palette_dirty + && !dbg_overlay + && !self.screenshot_pending.load(Ordering::Relaxed); + if screen.status_bar_only { + let h = screen.height.max(1); + let w = screen.width.max(1); + screen.dirty_y0 = h.saturating_sub(crate::disp::STATUS_BAR_HEIGHT); + screen.dirty_y1 = h; + screen.dirty_x0 = 0; + screen.dirty_x1 = w; + } else { + let y0 = self.dirty_y_min.swap(u32::MAX, Ordering::Relaxed); + let y1 = self.dirty_y_max.swap(0, Ordering::Relaxed); + if y0 != u32::MAX && y1 >= y0 { + screen.dirty_y0 = y0 as usize; + screen.dirty_y1 = (y1 as usize + 1).min(screen.height.max(1)); + } else { + screen.dirty_y0 = 0; + screen.dirty_y1 = screen.height.max(1); + } + let x0 = self.dirty_x_min.swap(u32::MAX, Ordering::Relaxed); + let x1 = self.dirty_x_max.swap(0, Ordering::Relaxed); + if x0 != u32::MAX && x1 >= x0 { + screen.dirty_x0 = x0 as usize; + screen.dirty_x1 = (x1 as usize + 1).min(screen.width.max(1)); + } else { + screen.dirty_x0 = 0; + screen.dirty_x1 = screen.width.max(1); + } + } // Push debug state into overlay overlay.show_cmap = self.show_cmap.load(Ordering::Relaxed); @@ -3530,9 +3885,17 @@ impl Rex3 { self.diag.fetch_or(Self::DIAG_LOCK_RENDERER, Ordering::Relaxed); let mut renderer = self.renderer.lock(); + let h = screen.height.max(1); + let w = screen.width.max(1); + let partial_fb = fb_was_dirty + && (screen.dirty_y1.saturating_sub(screen.dirty_y0) < h + || screen.dirty_x1.saturating_sub(screen.dirty_x0) < w); + let copy_full_fb = fb_was_dirty && !partial_fb; + let resized = screen.refresh( &**fb_rgb, &**fb_aux, + copy_full_fb, &self.vc2, &self.xmap0, &self.cmap0, @@ -3569,10 +3932,24 @@ impl Rex3 { if screen.width > 0 && screen.height > 0 { if let Some(ref mut r) = *renderer { self.diag.fetch_or(Self::DIAG_LOOP_GL_RENDER, Ordering::Relaxed); - r.present(&mut *screen, &mut overlay, &mut status_bar, &mut sbtex, &bar_stats, take_screenshot); + let borrow = screen.fb_borrowed; + r.present( + &mut *screen, + &mut overlay, + &mut status_bar, + &mut sbtex, + &bar_stats, + take_screenshot, + if borrow { Some(&**fb_rgb) } else { None }, + if borrow { Some(&**fb_aux) } else { None }, + ); self.diag.fetch_and(!Self::DIAG_LOOP_GL_RENDER, Ordering::Relaxed); } + if !screen.status_bar_only { + full_presents = full_presents.saturating_add(1); + } + if take_screenshot { let width = screen.width; let height = screen.height; @@ -3781,19 +4158,24 @@ impl Rex3 { } pub fn save_framebuffers(&self, dir: &std::path::Path) -> std::io::Result<()> { + self.save_framebuffers_named(dir, "rex3") + } + + /// Save RGB/aux planes with a custom filename prefix (e.g. `rex3_head1`). + pub fn save_framebuffers_named(&self, dir: &std::path::Path, prefix: &str) -> std::io::Result<()> { let rgb = unsafe { &*self.fb_rgb.get() }; let mut bytes = Vec::with_capacity(rgb.len() * 4); for &word in rgb.iter() { bytes.extend_from_slice(&word.to_be_bytes()); } - std::fs::write(dir.join("rex3_rgb.bin"), &bytes)?; + std::fs::write(dir.join(format!("{prefix}_rgb.bin")), &bytes)?; let aux = unsafe { &*self.fb_aux.get() }; bytes.clear(); for &word in aux.iter() { bytes.extend_from_slice(&word.to_be_bytes()); } - std::fs::write(dir.join("rex3_aux.bin"), &bytes)?; + std::fs::write(dir.join(format!("{prefix}_aux.bin")), &bytes)?; Ok(()) } @@ -3820,7 +4202,11 @@ impl Rex3 { } pub fn load_framebuffers(&self, dir: &std::path::Path) -> std::io::Result<()> { - let path_rgb = dir.join("rex3_rgb.bin"); + self.load_framebuffers_named(dir, "rex3") + } + + pub fn load_framebuffers_named(&self, dir: &std::path::Path, prefix: &str) -> std::io::Result<()> { + let path_rgb = dir.join(format!("{prefix}_rgb.bin")); if path_rgb.exists() { let bytes = std::fs::read(path_rgb)?; let rgb = unsafe { &mut *self.fb_rgb.get() }; @@ -3832,7 +4218,7 @@ impl Rex3 { } } - let path_aux = dir.join("rex3_aux.bin"); + let path_aux = dir.join(format!("{prefix}_aux.bin")); if path_aux.exists() { let bytes = std::fs::read(path_aux)?; let aux = unsafe { &mut *self.fb_aux.get() }; @@ -3909,10 +4295,12 @@ impl Device for Rex3 { let rex3 = unsafe { std::mem::transmute::<&Rex3, &'static Rex3>(self) }; *self.processor_thread.lock() = Some(thread::Builder::new().name("REX3-Processor".to_string()).spawn(move || { + crate::thread_affinity::pin_current(crate::thread_affinity::PerfRole::Rex3Processor); rex3.register_processor() }).unwrap()); *self.refresh_thread.lock() = Some(thread::Builder::new().name("REX3-Refresh".to_string()).spawn(move || { + crate::thread_affinity::pin_current(crate::thread_affinity::PerfRole::Rex3Refresh); rex3.refresh_loop(); }).unwrap()); } @@ -4361,6 +4749,11 @@ impl BusDevice for Rex3 { let cb = self.vblank_cb.lock().clone(); if let Some(cb) = cb { cb(false); } } + let had_gfifo = self.config.status.fetch_and(!STATUS_GFIFO_INT, Ordering::Relaxed) & STATUS_GFIFO_INT != 0; + if had_gfifo { + let cb = self.fifo_full_cb.lock().clone(); + if let Some(cb) = cb { cb(false); } + } let dcb = self.dcb.lock(); if let Some(until) = dcb.backbusy_until { if std::time::Instant::now() < until { val |= STATUS_BACKBUSY; } diff --git a/src/rex3_jit/compiler.rs b/src/rex3_jit/compiler.rs index f25ba57..fb734c3 100644 --- a/src/rex3_jit/compiler.rs +++ b/src/rex3_jit/compiler.rs @@ -1529,8 +1529,7 @@ fn emit_draw_iline( let xmajor_bit = b.ins().band_imm(octant_v, OCTANT_XMAJOR as i64); let y_major_v = b.ins().icmp_imm(IntCC::Equal, xmajor_bit, 0); - // major = y_major ? |y2-y| : |x2-x| - // pixel_count = major + 1; capped at 32 if length32 + // major = max(|dx|,|dy|); pixel_count = major + 1 (mirrors draw_line_bresenham). let dx_abs = { let d = b.ins().isub(x2_v, x_init_v); let neg = b.ins().ineg(d); @@ -1543,7 +1542,8 @@ fn emit_draw_iline( let is_neg = b.ins().icmp_imm(IntCC::SignedLessThan, d, 0); b.ins().select(is_neg, neg, d) }; - let major_v = b.ins().select(y_major_v, dy_abs, dx_abs); + let dx_gt_dy = b.ins().icmp(IntCC::SignedGreaterThan, dx_abs, dy_abs); + let major_v = b.ins().select(dx_gt_dy, dx_abs, dy_abs); let pixel_count_v = b.ins().iadd_imm(major_v, 1); // iterate_one = !stoponx && !stopony (step-mode: always draw exactly 1 pixel) diff --git a/src/rex3_simd.rs b/src/rex3_simd.rs new file mode 100644 index 0000000..5b15d8e --- /dev/null +++ b/src/rex3_simd.rs @@ -0,0 +1,124 @@ +//! SIMD-friendly fast paths for common REX3 interpreter draws (pre rex-jit). + +use crate::rex3::{Rex3, Rex3Context, REX3_SCREEN_HEIGHT, REX3_SCREEN_WIDTH}; + +/// Try to fast-fill a fastclear BLOCK without per-pixel interpreter overhead. +/// Returns true when the entire primitive was handled. +pub fn try_fastclear_block(rex: &Rex3, ctx: &Rex3Context) -> bool { + if !ctx.drawmode1.fastclear() { + return false; + } + if ctx.drawmode0.enzpattern() || ctx.drawmode0.enlspattern() { + return false; + } + if ctx.drawmode0.colorhost() || ctx.drawmode0.alphahost() { + return false; + } + if ctx.drawmode0.adrmode() != 1 { + return false; // BLOCK only + } + + let x0 = (ctx.xstart >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let x1 = (ctx.xend >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let y0 = (ctx.ystart >> 11).clamp(0, REX3_SCREEN_HEIGHT as i32 - 1); + let y1 = (ctx.yend >> 11).clamp(0, REX3_SCREEN_HEIGHT as i32 - 1); + let (x_lo, x_hi) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + let (y_lo, y_hi) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; + + let color = Rex3::fastclear_color(ctx); + let wr_fn = unsafe { *rex.px_wr.get() }; + let mut rows = 0u64; + + for y in y_lo..=y_hi { + rex.note_fb_y(y); + for x in x_lo..=x_hi { + if let Some(addr) = rex.calculate_fb_address(x, y, ctx, true) { + wr_fn(rex, addr, color); + } + } + rows += 1; + } + + rex.simd_fill_rows.fetch_add(rows, std::sync::atomic::Ordering::Relaxed); + true +} + +/// Fast SRC logicop horizontal span (solid RGB, no blend/pattern/host). +pub fn try_src_span_rgb(rex: &Rex3, ctx: &Rex3Context) -> bool { + use crate::rex3::{DRAWMODE0_ADRMODE_SPAN, DRAWMODE1_LOGICOP_SRC}; + if ctx.drawmode0.adrmode() << 2 != DRAWMODE0_ADRMODE_SPAN { + return false; + } + if ctx.drawmode1.logicop() != DRAWMODE1_LOGICOP_SRC >> 28 { + return false; + } + if ctx.drawmode0.enzpattern() || ctx.drawmode0.enlspattern() { + return false; + } + if ctx.drawmode0.colorhost() || ctx.drawmode0.alphahost() || ctx.drawmode0.shade() { + return false; + } + if ctx.drawmode1.planes() != 0 { + return false; // RGB/RGBA planes only + } + + let x0 = (ctx.xstart >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let x1 = (ctx.xend >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let y = (ctx.ystart >> 11).clamp(0, REX3_SCREEN_HEIGHT as i32 - 1); + let (x_lo, x_hi) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + + let color = ctx.get_colori(); + let wr_fn = unsafe { *rex.px_wr.get() }; + rex.note_fb_y(y); + for x in x_lo..=x_hi { + if let Some(addr) = rex.calculate_fb_address(x, y, ctx, true) { + wr_fn(rex, addr, color); + } + } + rex.simd_fill_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + true +} + +/// Fast SRC logicop axis-aligned BLOCK fill (solid RGB, no blend/pattern/host). +pub fn try_src_block_rgb(rex: &Rex3, ctx: &Rex3Context) -> bool { + use crate::rex3::{DRAWMODE1_LOGICOP_SRC}; + if ctx.drawmode0.adrmode() != 1 { + return false; + } + if ctx.drawmode1.logicop() != DRAWMODE1_LOGICOP_SRC >> 28 { + return false; + } + if ctx.drawmode0.enzpattern() || ctx.drawmode0.enlspattern() { + return false; + } + if ctx.drawmode0.colorhost() || ctx.drawmode0.alphahost() || ctx.drawmode0.shade() { + return false; + } + if ctx.drawmode1.planes() != 0 || ctx.drawmode1.fastclear() { + return false; + } + + let x0 = (ctx.xstart >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let x1 = (ctx.xend >> 11).clamp(0, REX3_SCREEN_WIDTH as i32 - 1); + let y0 = (ctx.ystart >> 11).clamp(0, REX3_SCREEN_HEIGHT as i32 - 1); + let y1 = (ctx.yend >> 11).clamp(0, REX3_SCREEN_HEIGHT as i32 - 1); + let (x_lo, x_hi) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + let (y_lo, y_hi) = if y0 <= y1 { (y0, y1) } else { (y1, y0) }; + + let color = ctx.get_colori(); + let wr_fn = unsafe { *rex.px_wr.get() }; + let mut rows = 0u64; + + for y in y_lo..=y_hi { + rex.note_fb_y(y); + for x in x_lo..=x_hi { + if let Some(addr) = rex.calculate_fb_address(x, y, ctx, true) { + wr_fn(rex, addr, color); + } + } + rows += 1; + } + + rex.simd_fill_rows.fetch_add(rows, std::sync::atomic::Ordering::Relaxed); + true +} diff --git a/src/rex3_tests.rs b/src/rex3_tests.rs index e15a347..3f6410f 100644 --- a/src/rex3_tests.rs +++ b/src/rex3_tests.rs @@ -1807,6 +1807,73 @@ fn test_iline_skipfirst_skiplast() { assert_eq!(pts, expected, "skip_first+skip_last should omit both endpoints"); } +#[test] +fn test_iline_lspattern_stipple() { + let rex = make_rex3(); + rex3init(&rex); + // DRAW I_LINE with ENLSPATTERN; pattern has only MSB set. + let dm0 = DM0_DRAW_ILINE | (1 << 13); // enlspattern + let bx = 8i32; + let ex = 14i32; + let y = 40i32; + + reg(&rex, REX3_DRAWMODE1, DM1_CI8_SRC); + reg(&rex, REX3_WRMASK, 0xFF); + reg(&rex, REX3_COLORI, 0); + reg(&rex, REX3_XYENDI, xy(ex, y)); + reg(&rex, REX3_XYSTARTI, xy(bx, y)); + reg_go(&rex, REX3_DRAWMODE0, DM0_DRAW_BLOCK); + + reg(&rex, REX3_LSPATTERN, 0x8000_0000); + reg(&rex, REX3_LSMODE, 0); // length=17, repeat=1 + reg(&rex, REX3_COLORI, 0xEE); + reg(&rex, REX3_XYENDI, xy(ex, y)); + reg(&rex, REX3_XYSTARTI, xy(bx, y)); + reg_go(&rex, REX3_DRAWMODE0, dm0); + + let mut drawn = Vec::new(); + for x in bx..=ex { + if read_pixel(&rex, x, y) & 0xFF == 0xEE { + drawn.push(x); + } + } + // MSB-only pattern: first pixel on, then off for the rest. + assert_eq!(drawn, vec![bx], "stippled I_LINE should draw only pattern-on pixels"); +} + +#[test] +fn test_iline_lsadvlast_advances_on_last_pixel() { + let rex = make_rex3(); + rex3init(&rex); + let y = 50i32; + let dm0 = DM0_DRAW_ILINE | (1 << 13) | (1 << 14); // enlspattern + lsadvlast + + reg(&rex, REX3_DRAWMODE1, DM1_CI8_SRC); + reg(&rex, REX3_WRMASK, 0xFF); + reg(&rex, REX3_COLORI, 0); + reg(&rex, REX3_XYENDI, xy(20, y)); + reg(&rex, REX3_XYSTARTI, xy(8, y)); + reg_go(&rex, REX3_DRAWMODE0, DM0_DRAW_BLOCK); + + reg(&rex, REX3_LSPATTERN, 0x8000_0000); + reg(&rex, REX3_LSMODE, 0); + // First segment: single pixel at x=8 + reg(&rex, REX3_COLORI, 0xAA); + reg(&rex, REX3_XYENDI, xy(8, y)); + reg(&rex, REX3_XYSTARTI, xy(8, y)); + reg_go(&rex, REX3_DRAWMODE0, dm0); + + // Second segment without DOSETUP: continue from x=8 (persisted xstart), pat_bit advanced. + let dm0_cont = dm0 & !(1 << 5); // clear dosetup + reg(&rex, REX3_COLORI, 0xBB); + reg(&rex, REX3_XYENDI, xy(10, y)); + reg_go(&rex, REX3_DRAWMODE0, dm0_cont); + + assert_eq!(read_pixel(&rex, 8, y) & 0xFF, 0xAA); + assert_eq!(read_pixel(&rex, 9, y) & 0xFF, 0, "stipple advanced past MSB — pixel 9 should be off"); + assert_eq!(read_pixel(&rex, 10, y) & 0xFF, 0, "pixel 10 should be off"); +} + // --- All octants, r=32 circle, full draw --- #[test] diff --git a/src/thread_affinity.rs b/src/thread_affinity.rs new file mode 100644 index 0000000..54375a4 --- /dev/null +++ b/src/thread_affinity.rs @@ -0,0 +1,82 @@ +//! Optional host thread affinity for emulator worker threads. + +use std::sync::OnceLock; + +use crate::config::PerfConfig; + +static PERF: OnceLock = OnceLock::new(); + +/// Called once from `Machine::new` with the loaded `[perf]` section. +pub fn init(perf: PerfConfig) { + let _ = PERF.set(perf); +} + +#[derive(Clone, Copy, Debug)] +pub enum PerfRole { + MipsCpu, + Rex3Processor, + Rex3Refresh, +} + +/// Pin the current thread when `[perf] thread_affinity` is enabled. +pub fn pin_current(role: PerfRole) { + let Some(perf) = PERF.get() else { return; }; + if !perf.thread_affinity { + return; + } + let core = match role { + PerfRole::MipsCpu => perf.cpu_core, + PerfRole::Rex3Processor => perf.rex3_core, + PerfRole::Rex3Refresh => perf.refresh_core, + }; + let Some(core) = core else { return; }; + if apply_core(core) { + eprintln!("iris: pinned {:?} thread to core {}", role, core); + } +} + +#[cfg(windows)] +fn apply_core(core: u32) -> bool { + let mask = 1usize << core.min(63); + unsafe { + windows_sys::Win32::System::Threading::SetThreadAffinityMask( + windows_sys::Win32::System::Threading::GetCurrentThread(), + mask, + ) != 0 + } +} + +#[cfg(target_os = "linux")] +fn apply_core(core: u32) -> bool { + use std::mem::MaybeUninit; + let mut set: libc::cpu_set_t = unsafe { MaybeUninit::zeroed().assume_init() }; + unsafe { + libc::CPU_ZERO(&mut set); + libc::CPU_SET(core as usize, &mut set); + libc::sched_setaffinity(0, std::mem::size_of_val(&set), &set) == 0 + } +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn apply_core(_core: u32) -> bool { + false +} + +#[cfg(not(any(windows, unix)))] +fn apply_core(_core: u32) -> bool { + false +} + +/// One-line summary for `perf snapshot`. +pub fn status_line() -> String { + let Some(perf) = PERF.get() else { + return "thread_affinity: (not initialized)".to_string(); + }; + if !perf.thread_affinity { + return "thread_affinity: off".to_string(); + } + format!( + "thread_affinity: on cpu={:?} rex3={:?} refresh={:?}", + perf.cpu_core, perf.rex3_core, perf.refresh_core + ) +} diff --git a/src/ui.rs b/src/ui.rs index 4c5b05d..f7ba2ff 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -346,6 +346,8 @@ impl Renderer for GlRenderer { sbtex: &mut StatusBarTexture, stats: &BarStats, need_readback: bool, + live_fb_rgb: Option<&[u32]>, + live_fb_aux: Option<&[u32]>, ) { if self.state.is_none() { self.init_gl(); @@ -429,7 +431,7 @@ impl Renderer for GlRenderer { unsafe { // ── Compositor runs first — it sets its own FBO/viewport ───────── - let src = screen.compositor_source(); + let src = screen.compositor_source_from(live_fb_rgb, live_fb_aux); let main_tex = self.compositor.compose(&src, gl); // Restore full-window viewport/scissor — compositor FBO left them dirty. diff --git a/src/ultra64.rs b/src/ultra64.rs index d0c157f..94ff06f 100644 --- a/src/ultra64.rs +++ b/src/ultra64.rs @@ -36,6 +36,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::JoinHandle; use shared_memory::ShmemConf; use raw_sync::{events::{Event, EventImpl, EventInit, EventState}, Timeout}; +#[cfg(unix)] use libc; /// Wraps a `Box` and asserts Send + Sync. diff --git a/src/vc2_timings.rs b/src/vc2_timings.rs new file mode 100644 index 0000000..1190fe4 --- /dev/null +++ b/src/vc2_timings.rs @@ -0,0 +1,184 @@ +//! Prebuilt Newport (VC2) video timing tables for host-selected display modes. +//! +//! IRIX normally programs VC2 via the X/setmon path; these presets let the GUI +//! force standard 4:3 / 5:4 Indy resolutions at VM start before the guest runs. + +use serde::{Deserialize, Serialize}; + +use crate::vc2::{ + Vc2, VC2_CTRL_BLACKOUT, VC2_CTRL_CURSOR_EN, VC2_CTRL_CURSOR_FUNC_EN, + VC2_CTRL_VIDEO_TIMING_EN, VC2_REG_DISPLAY_CONTROL, VC2_REG_SCANLINE_LEN, + VC2_REG_VIDEO_ENTRY_PTR, VC2_REG_VT_FRAME_PTR, VT_CBLANK_XMAP_N, VT_HPOS_VC_N, +}; + +/// Standard Newport modes that fit the 1280×1024 framebuffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NewportResolution { + /// Leave VC2 untouched — IRIX/PROM programs the mode. + #[default] + Guest, + /// 1024×768 @ ~60 Hz (4:3). + #[serde(rename = "1024x768")] + Res1024x768, + /// 1280×960 (4:3 within 1024-line VRAM). + #[serde(rename = "1280x960")] + Res1280x960, + /// 1280×1024 @ ~72 Hz (5:4 Indy default). + #[serde(rename = "1280x1024")] + Res1280x1024, +} + +impl NewportResolution { + pub fn label(self) -> &'static str { + match self { + Self::Guest => "Guest (IRIX / setmon)", + Self::Res1024x768 => "1024×768", + Self::Res1280x960 => "1280×960", + Self::Res1280x1024 => "1280×1024 (default)", + } + } + + pub fn is_guest(self) -> bool { + matches!(self, Self::Guest) + } + + /// Visible pixel size for host window sizing; `None` when guest-controlled. + pub fn visible_size(self) -> Option<(u32, u32)> { + match self { + Self::Guest => None, + Self::Res1024x768 => Some((1024, 768)), + Self::Res1280x960 => Some((1280, 960)), + Self::Res1280x1024 => Some((1280, 1024)), + } + } + + fn geometry(self) -> Option<(u16, u16, u16)> { + // (width, height, horizontal blank before active in pixels) + match self { + Self::Guest => None, + Self::Res1024x768 => Some((1024, 768, 320)), + Self::Res1280x960 => Some((1280, 960, 408)), + Self::Res1280x1024 => Some((1280, 1024, 408)), + } + } +} + +/// Decode visible width/height from VC2 register file + RAM (shared with compositor). +pub fn decode_vc2_visible_size(regs: &[u16; 32], ram: &[u16]) -> (usize, usize) { + use crate::disp::decode_vc2_timings; + let (w, h, _) = decode_vc2_timings(regs, ram); + (w, h) +} + +fn pack_line_word(duration: u8, state_a: u8, state_c: u8, eol: bool) -> [u16; 2] { + let w1 = ((eol as u16) << 15) | ((duration as u16) << 8) | (state_a as u16); + [w1, state_c as u16] +} + +fn push_pixels( + ram: &mut [u16], + ptr: &mut usize, + pixels: u16, + state_a: u8, + state_c: u8, + eol: bool, +) { + let mut left = pixels; + while left > 0 { + let chunk_px = left.min(254); + let dur = (chunk_px / 2).max(1) as u8; + let words = pack_line_word(dur, state_a, state_c, eol && left <= chunk_px); + ram[*ptr] = words[0]; + ram[*ptr + 1] = words[1]; + *ptr += 2; + left = left.saturating_sub(chunk_px); + } +} + +fn build_mode_ram(width: u16, height: u16, h_blank_lead: u16) -> ([u16; 32], Vec) { + const FRAME_PTR: usize = 0x100; + const LINE_SEQ_PTR: usize = 0x200; + + let mut ram = vec![0u16; 32768]; + let mut seq = LINE_SEQ_PTR; + + // Horizontal blanking before active video. + if h_blank_lead > 0 { + push_pixels(&mut ram, &mut seq, h_blank_lead, 0, 0, false); + } + // HPOS pulse so cursor timing decode finds a leading edge. + { + let words = pack_line_word(2, VT_HPOS_VC_N, 0, false); + ram[seq] = words[0]; + ram[seq + 1] = words[1]; + seq += 2; + let words = pack_line_word(2, 0, 0, false); + ram[seq] = words[0]; + ram[seq + 1] = words[1]; + seq += 2; + } + // Active video: composite blank deasserted to XMAP. + push_pixels(&mut ram, &mut seq, width, 0, VT_CBLANK_XMAP_N, true); + + // Frame table: one run of `height` identical lines. + ram[FRAME_PTR] = LINE_SEQ_PTR as u16; + ram[FRAME_PTR + 1] = height; + + let mut regs = [0u16; 32]; + regs[VC2_REG_VIDEO_ENTRY_PTR as usize] = FRAME_PTR as u16; + regs[VC2_REG_VT_FRAME_PTR as usize] = FRAME_PTR as u16; + regs[VC2_REG_SCANLINE_LEN as usize] = width << 5; + regs[VC2_REG_DISPLAY_CONTROL as usize] = VC2_CTRL_BLACKOUT + | VC2_CTRL_VIDEO_TIMING_EN + | VC2_CTRL_CURSOR_FUNC_EN + | VC2_CTRL_CURSOR_EN; + + (regs, ram) +} + +/// Program VC2 RAM/regs for a standard Newport resolution. +pub fn apply_newport_resolution(vc2: &mut Vc2, mode: NewportResolution) { + let Some((width, height, h_blank)) = mode.geometry() else { + return; + }; + let (regs, ram) = build_mode_ram(width, height, h_blank); + vc2.regs = regs; + vc2.ram = ram; + vc2.dirty = true; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_decode_to_requested_size() { + for mode in [ + NewportResolution::Res1024x768, + NewportResolution::Res1280x960, + NewportResolution::Res1280x1024, + ] { + let Some((ew, eh, _)) = mode.geometry().map(|(w, h, b)| (w as usize, h as usize, b)) else { + continue; + }; + let (regs, ram) = { + let (r, ram) = build_mode_ram( + mode.geometry().unwrap().0, + mode.geometry().unwrap().1, + mode.geometry().unwrap().2, + ); + (r, ram) + }; + let (w, h) = decode_vc2_visible_size(®s, &ram); + assert_eq!(h, eh, "mode {:?} height", mode); + assert!( + (w as i32 - ew as i32).abs() <= 4, + "mode {:?}: width {} (expected {})", + mode, + w, + ew + ); + } + } +} diff --git a/src/video_source.rs b/src/video_source.rs index cb0b2e0..31d1e01 100644 --- a/src/video_source.rs +++ b/src/video_source.rs @@ -62,6 +62,44 @@ pub trait VideoSource: Send + Sync { fn status(&self) -> String { "no status available".to_string() } } +// ─── CDMC register → pixel pipeline (Phase 3) ──────────────────────────────── + +/// Applies IndyCam CDMC gain/balance/saturation to UYVY fields after capture. +pub struct CdmcAdjustedSource { + inner: Arc, + vino: crate::vino::Vino, +} + +impl CdmcAdjustedSource { + pub fn new(inner: Arc, vino: crate::vino::Vino) -> Self { + Self { inner, vino } + } +} + +impl VideoSource for CdmcAdjustedSource { + fn standard(&self) -> VideoStandard { self.inner.standard() } + + fn next_field(&self) -> Field { + let mut field = self.inner.next_field(); + let regs = self.vino.cdmc_regs(); + let mut pixels = field.pixels.to_vec(); + crate::cdmc::Cdmc::apply_uyvy_field(&mut pixels, ®s); + field.pixels = Arc::from(pixels); + field + } + + fn status(&self) -> String { + let regs = self.vino.cdmc_regs(); + format!( + "{} cdmc gain={:#04x} red_bal={:#04x} blue_bal={:#04x}", + self.inner.status(), + regs[crate::cdmc::reg::GAIN as usize], + regs[crate::cdmc::reg::RED_BAL as usize], + regs[crate::cdmc::reg::BLUE_BAL as usize], + ) + } +} + // ─── Black source: emits solid black fields at the standard's field rate ───── pub struct BlackSource { diff --git a/src/vino.rs b/src/vino.rs index b60e382..f275f78 100644 --- a/src/vino.rs +++ b/src/vino.rs @@ -359,7 +359,10 @@ pub struct Vino { state: Arc>, irq: Arc>>>, sys_mem: Arc>>>, - source: Arc>>>, + /// D0 / composite (SAA7191 path) — defaults to black field generator. + source_d0: Arc>>>, + /// D1 / IndyCam (CDMC path) — set via set_source() from machine config. + source_d1: Arc>>>, wake: Arc, running: Arc, thread: Arc>>>, @@ -371,7 +374,10 @@ impl Vino { state: Arc::new(Mutex::new(VinoState::default())), irq: Arc::new(Mutex::new(None)), sys_mem: Arc::new(Mutex::new(None)), - source: Arc::new(Mutex::new(None)), + source_d0: Arc::new(Mutex::new(Some(Arc::new( + crate::video_source::BlackSource::new(crate::video_source::VideoStandard::Ntsc), + ) as Arc))), + source_d1: Arc::new(Mutex::new(None)), wake: DmaWake::new(), running: Arc::new(AtomicBool::new(false)), thread: Arc::new(Mutex::new(None)), @@ -388,13 +394,23 @@ impl Vino { *self.sys_mem.lock() = Some(mem); } - /// Install the video input source. Shared by both channels (per-port - /// routing via the SELECT_D1 control bit is a later-phase concern). + /// Install the D1 (IndyCam / CDMC) video input source. pub fn set_source(&self, src: Arc) { - *self.source.lock() = Some(src); + *self.source_d1.lock() = Some(src); self.wake.notify(); } + /// Install the D0 (composite / SAA7191) video input source. + pub fn set_source_d0(&self, src: Arc) { + *self.source_d0.lock() = Some(src); + self.wake.notify(); + } + + /// Read CDMC register file for pixel pipeline adjustments. + pub fn cdmc_regs(&self) -> [u8; crate::cdmc::reg::COUNT] { + self.state.lock().cdmc.regs_copy() + } + // ── Power-on reset ──────────────────────────────────────────────────── pub fn power_on(&self) { @@ -919,21 +935,38 @@ impl Vino { Some(m) => m, None => { thread::sleep(Duration::from_millis(10)); continue; } }; - let src = match self.source.lock().clone() { - Some(s) => s, - None => { thread::sleep(Duration::from_millis(10)); continue; } - }; - // Blocks one field period; the source paces itself. - let field = src.next_field(); - - let (a_en, b_en) = { + let (control, a_en, b_en) = { let st = self.state.lock(); - (st.control & ctrl::CHA_DMA_EN != 0, - st.control & ctrl::CHB_DMA_EN != 0) + ( + st.control, + st.control & ctrl::CHA_DMA_EN != 0, + st.control & ctrl::CHB_DMA_EN != 0, + ) + }; + + let field_for = |ch: usize| -> Option { + let select_d1 = if ch == 0 { ctrl::CHA_SELECT_D1 } else { ctrl::CHB_SELECT_D1 }; + let src = if control & select_d1 != 0 { + self.source_d1.lock().clone()? + } else { + self.source_d0.lock().clone()? + }; + Some(src.next_field()) }; - if a_en { self.pump_field(0, &field, &mem); } - if b_en { self.pump_field(1, &field, &mem); } + + if a_en { + match field_for(0) { + Some(field) => self.pump_field(0, &field, &mem), + None => { thread::sleep(Duration::from_millis(10)); continue; } + } + } + if b_en { + match field_for(1) { + Some(field) => self.pump_field(1, &field, &mem), + None => { thread::sleep(Duration::from_millis(10)); continue; } + } + } } } @@ -1145,7 +1178,7 @@ impl Vino { reg::CH_CLIP_END => chan.clip_end = val & clip::REG_MASK, reg::CH_FRAME_RATE => { chan.frame_rate = val & frame_rate::REG_MASK; - // TODO: recompute frame-mask shifter + chan.field_counter = 0; } reg::CH_FIELD_COUNTER => { /* read-only, ignore */ } reg::CH_LINE_SIZE => chan.line_size = val & 0x0FF8, @@ -1397,11 +1430,16 @@ impl Device for Vino { writeln!(writer, "VINO Status (debug {})", if log { "on" } else { "off" }) .map_err(|e| e.to_string())?; - let src_status = self.source.lock() + let d0_status = self.source_d0.lock() + .as_ref() + .map(|s| s.status()) + .unwrap_or_else(|| "none".to_string()); + let d1_status = self.source_d1.lock() .as_ref() .map(|s| s.status()) - .unwrap_or_else(|| "no source installed".to_string()); - writeln!(writer, " source: {}", src_status).map_err(|e| e.to_string())?; + .unwrap_or_else(|| "none".to_string()); + writeln!(writer, " source D0 (composite): {}", d0_status).map_err(|e| e.to_string())?; + writeln!(writer, " source D1 (IndyCam): {}", d1_status).map_err(|e| e.to_string())?; writeln!(writer, " REV_ID = {:#010x} (chip_id={:#x} rev={})", st.rev_id, (st.rev_id >> 4) & 0xF, st.rev_id & 0xF) .map_err(|e| e.to_string())?; diff --git a/src/xz.rs b/src/xz.rs new file mode 100644 index 0000000..3d22ed4 --- /dev/null +++ b/src/xz.rs @@ -0,0 +1,242 @@ +/// Indy XZ / Elan (GR3) graphics — preview stub +/// +/// The Indy XZ option replaces Newport (REX3) with an Express-class GR3 board: +/// HQ2 command engine, four GE7 geometry engines, RE3 raster engine, VC1/XMAP5 +/// display path. IRIX uses the `gfx` (Express) driver stack, not Newport. +/// +/// **Preview only:** register storage and probe-friendly ID/status defaults. +/// No command FIFO draining, geometry, raster, or framebuffer. Selecting +/// `graphics.board = "xz"` disables the Newport window/compositor path. +/// +/// GIO mapping matches the Newport XL layout used by MAME (`newport.cpp` mem_map): +/// the CPU register window sits at offset `+0x0F0000` within the 4 MB gfx slot +/// (`0x1F000000`–`0x1F3FFFFF` on IP24). +/// +/// References: +/// `docs/indy-xz-elan.md` +/// MAME `src/devices/bus/gio64/newport.cpp` (GIO slot layout) +/// SGI Indy Technical Report ch.5 (XZ architecture overview) + +use parking_lot::Mutex; +use std::io::Write as IoWrite; + +use crate::devlog::LogModule; +use crate::snapshot::{get_field, hex_u32, toml_u32}; +use crate::traits::{BusDevice, BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, Device, Saveable}; + +// ─── GIO64 mapping (Indy IP24 gfx slot) ───────────────────────────────────── + +/// Physical base of the 4 MB GIO gfx aperture. +pub const XZ_GIO_BASE: u32 = 0x1F00_0000; +pub const XZ_GIO_SIZE: u32 = 0x0040_0000; + +/// HQ2 / CPU register window (same offset within the slot as REX3 on Newport). +pub const XZ_REG_BASE: u32 = 0x1F0F_0000; +pub const XZ_REG_SIZE: u32 = 0x2000; + +// ─── Register offsets (relative to XZ_REG_BASE) ───────────────────────────── +// +// HQ2 is a large gate array; public sources disagree on the fine-grained map. +// Offsets below follow the IRIX/Linux Express driver grouping (board ID, status, +// interrupt, command FIFO port) documented in `docs/indy-xz-elan.md`. Treat +// unlisted offsets as reserved — reads return 0, writes are logged and ignored. + +pub mod reg { + /// Board / architecture ID (read-only). Low byte encodes GR generation. + pub const BOARD_ID: u32 = 0x0000; + /// HQ2 revision (read-only). + pub const REVISION: u32 = 0x0004; + /// Command-engine status: FIFO level, engine busy (preview: always idle). + pub const STATUS: u32 = 0x0008; + /// Interrupt status (write-1-to-clear bits in hardware; stub stores written val). + pub const INTR_STATUS: u32 = 0x000C; + /// Interrupt enable mask. + pub const INTR_ENABLE: u32 = 0x0010; + /// Command FIFO write port (writes accepted, not executed). + pub const FIFO_WRITE: u32 = 0x0018; + /// FIFO read/debug port (hardware-dependent; stub returns 0). + pub const FIFO_READ: u32 = 0x001C; + /// Reset / soft-reset control. + pub const RESET: u32 = 0x0020; + + pub const BOARD_ID_VAL: u32 = 0x0003_0001; // GR3 family, XZ variant (research placeholder) + pub const REVISION_VAL: u32 = 0x0000_0021; // HQ2.1 per Indy `gfxinfo` reports + + /// STATUS: FIFO empty (bit 0), GE idle (bit 1), RE idle (bit 2). + pub const STATUS_FIFO_EMPTY: u32 = 1 << 0; + pub const STATUS_GE_IDLE: u32 = 1 << 1; + pub const STATUS_RE_IDLE: u32 = 1 << 2; + pub const STATUS_RESET_VAL: u32 = + STATUS_FIFO_EMPTY | STATUS_GE_IDLE | STATUS_RE_IDLE; +} + +struct XzState { + intr_status: u32, + intr_enable: u32, + fifo_depth: u32, // bytes accepted (diagnostic counter only) +} + +impl Default for XzState { + fn default() -> Self { + Self { intr_status: 0, intr_enable: 0, fifo_depth: 0 } + } +} + +#[derive(Clone)] +pub struct Xz { + state: std::sync::Arc>, +} + +impl Xz { + pub fn new() -> Self { + Self { state: std::sync::Arc::new(Mutex::new(XzState::default())) } + } + + pub fn power_on(&self) { + *self.state.lock() = XzState::default(); + } + + fn in_regs(addr: u32) -> bool { + (addr & 0xFFFF_E000) == XZ_REG_BASE + } + + fn reg_offset(addr: u32) -> u32 { + addr & (XZ_REG_SIZE - 1) + } + + fn read_reg(&self, off: u32) -> u32 { + let st = self.state.lock(); + match off { + reg::BOARD_ID => reg::BOARD_ID_VAL, + reg::REVISION => reg::REVISION_VAL, + reg::STATUS => reg::STATUS_RESET_VAL, + reg::INTR_STATUS => st.intr_status, + reg::INTR_ENABLE => st.intr_enable, + reg::FIFO_READ => 0, + _ => 0, + } + } + + fn write_reg(&self, off: u32, val: u32) { + let mut st = self.state.lock(); + match off { + reg::INTR_STATUS => st.intr_status &= !val, + reg::INTR_ENABLE => st.intr_enable = val, + reg::FIFO_WRITE => { + st.fifo_depth = st.fifo_depth.saturating_add(4); + dlog_dev!(LogModule::Rex3, "XZ: FIFO write {:08x} (stub, depth={})", val, st.fifo_depth); + } + reg::RESET => { + *st = XzState::default(); + } + _ => { + dlog_dev!(LogModule::Rex3, "XZ: write reg {:04x} = {:08x} (ignored)", off, val); + } + } + } +} + +impl Device for Xz { + fn step(&self, _cycles: u64) {} + fn stop(&self) {} + fn start(&self) {} + fn is_running(&self) -> bool { false } + fn get_clock(&self) -> u64 { 0 } + + fn register_commands(&self) -> Vec<(String, String)> { + vec![("xz".into(), "XZ/Elan preview stub (status)".into())] + } + + fn execute_command(&self, cmd: &str, args: &[&str], mut writer: Box) -> Result<(), String> { + if cmd != "xz" { + return Err(format!("unknown xz command: {cmd}")); + } + if !args.is_empty() { + return Err("usage: xz".into()); + } + let st = self.state.lock(); + writeln!( + writer, + "XZ/Elan preview stub @ {:#010x}..{:#010x}\n fifo_bytes_accepted={}\n intr_status={:#010x} intr_enable={:#010x}", + XZ_REG_BASE, + XZ_REG_BASE + XZ_REG_SIZE, + st.fifo_depth, + st.intr_status, + st.intr_enable, + ) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +impl BusDevice for Xz { + fn read32(&self, addr: u32) -> BusRead32 { + if !Self::in_regs(addr) { + return BusRead32::ok(0); + } + BusRead32::ok(self.read_reg(Self::reg_offset(addr))) + } + + fn write32(&self, addr: u32, val: u32) -> u32 { + if Self::in_regs(addr) { + self.write_reg(Self::reg_offset(addr), val); + } + BUS_OK + } + + fn read8(&self, addr: u32) -> BusRead8 { + BusRead8::ok(self.read32(addr & !3).data as u8) + } + fn write8(&self, addr: u32, val: u8) -> u32 { + let shift = (addr & 3) * 8; + let cur = self.read32(addr & !3).data; + self.write32(addr & !3, (cur & !(0xFF << shift)) | ((val as u32) << shift)); + BUS_OK + } + fn read16(&self, addr: u32) -> BusRead16 { + BusRead16::ok(self.read32(addr & !3).data as u16) + } + fn write16(&self, addr: u32, val: u16) -> u32 { + let shift = if (addr & 2) != 0 { 16 } else { 0 }; + let cur = self.read32(addr & !3).data; + self.write32(addr & !3, (cur & !(0xFFFF << shift)) | ((val as u32) << shift)); + BUS_OK + } + fn read64(&self, addr: u32) -> BusRead64 { + let lo = self.read32(addr).data as u64; + let hi = self.read32(addr.wrapping_add(4)).data as u64; + BusRead64::ok((hi << 32) | lo) + } + fn write64(&self, addr: u32, val: u64) -> u32 { + self.write32(addr, val as u32); + self.write32(addr.wrapping_add(4), (val >> 32) as u32); + BUS_OK + } +} + +impl Saveable for Xz { + fn save_state(&self) -> toml::Value { + let st = self.state.lock(); + let mut tbl = toml::map::Map::new(); + tbl.insert("intr_status".into(), hex_u32(st.intr_status)); + tbl.insert("intr_enable".into(), hex_u32(st.intr_enable)); + tbl.insert("fifo_depth".into(), toml::Value::Integer(st.fifo_depth as i64)); + toml::Value::Table(tbl) + } + + fn load_state(&self, v: &toml::Value) -> Result<(), String> { + let mut st = self.state.lock(); + if let Some(x) = get_field(v, "intr_status") { + st.intr_status = toml_u32(x).unwrap_or(st.intr_status); + } + if let Some(x) = get_field(v, "intr_enable") { + st.intr_enable = toml_u32(x).unwrap_or(st.intr_enable); + } + if let Some(x) = get_field(v, "fifo_depth") { + if let Some(n) = x.as_integer() { + st.fifo_depth = n as u32; + } + } + Ok(()) + } +} diff --git a/tools/tests/indigo2-prom-smoke.yaml b/tools/tests/indigo2-prom-smoke.yaml new file mode 100644 index 0000000..ca443cf --- /dev/null +++ b/tools/tests/indigo2-prom-smoke.yaml @@ -0,0 +1,15 @@ +name: indigo2-prom-smoke +# Cold-boot Indigo2 IP22 into PROM (requires irix-install media). +# Start: iris --config irix-install/iris-indigo2-smoke-ci.toml +# Run: tools/iris-test tools/tests/indigo2-prom-smoke.yaml --no-restore +# +# After boot, monitor `mc status` should show SYSID 00000010 and `ioc status` +# sys_id=11 — see docs/indigo2-ip22.md for the full checklist. + +steps: + - type: sleep + seconds: 1 + + - type: serial + expect: "SGI" + timeout: 90 diff --git a/wsl/README.md b/wsl/README.md new file mode 100644 index 0000000..b49a13d --- /dev/null +++ b/wsl/README.md @@ -0,0 +1,323 @@ +# IRIS on Windows / WSL + +This folder is the **Windows 11 daily-driver guide** for this repo copy (`CURSOR-PROJECTS/iris-main`). A parallel WSL build often lives at `~/iris-wsl-build`. + +## One-click launch (Windows) + +| Script | What it does | +|--------|----------------| +| `run-iris-premiere.bat` | **Recording:** full-feature `iris.exe` CLI + JIT env (best 3D/X11 perf) | +| `run-iris-premiere-nojit.bat` | **A/B:** same config, `IRIS_JIT=0` (interpreter — test silent app quits) | +| `run-iris-premiere-verify.bat` | JIT + `IRIS_JIT_VERIFY=1` (slow; catches codegen mismatches) | +| `capture-app-crash.ps1` | Tee stderr to `premiere-debug.log` + on-screen capture checklist | +| `run-iris-windows.bat` | IRIS CLI with full performance features + JIT env | +| `run-iris-gui-windows.bat` | iris-gui launcher (configure machines in the UI) | +| `run-iris-gui-premiere.bat` | **Premiere GUI:** `premiere` feature (lightning+idle-pause) + JIT + `IRIS_GUI_GL=1` | +| `run-iris-ci.bat` | Headless CI with `iris-windows.toml` (TCP `127.0.0.1:19851`) | +| `run-iris.bat` | IRIS CLI via WSL (iris-ci, Linux tools) | +| `run-iris-gui.bat` | iris-gui via WSL | + +**For YouTube / max graphics:** tune the machine in **iris-gui**, **export** to `irix-install/iris-windows.toml`, **stop** the GUI VM, then run **`run-iris-premiere.bat`** for the 3D segment (native CLI OpenGL — faster than the GUI CPU compositor). + +--- + +## Local mods (this tree) + +Changes beyond upstream IRIS that affect how you run and configure the Indy: + +### Performance stack + +| Area | What | +|------|------| +| **Launch scripts** | `run-iris-premiere.bat` / `run-iris-windows.bat` set `IRIS_JIT`, probe 500/min 100, **tier 1** (Loads); **feature stamp** rebuild via `ensure-build.bat` | +| **Premiere GUI** | `cargo build -p iris-gui --features premiere` — embedded `lightning` + `idle-pause`; `run-iris-gui-premiere.bat` | +| **GUI prefs** | JIT + OpenGL capture persisted in `gui.json`; **File → Prepare for premiere…** exports TOML | +| **Idle refresh** | Status-bar-only heartbeat skips full compositor + partial egui upload — [rules/perf/gui-idle-refresh.md](../rules/perf/gui-idle-refresh.md) | +| **Audio** | hptimer late-fire catch-up; Display tab `[audio]` prebuf / cpal buffer | + +See [HELP.md](../HELP.md) for monitor commands, serial ports, NVRAM, etc. + +### VM hardware / RAM (iris-gui + core) + +| Area | What | +|------|------| +| **RAM presets** | Memory menu + Memory tab: **384 MB** and **512 MB** (plus 32–256 MB) | +| **RAM workflow** | Edits disabled while VM is running; **“Applied at next Start”** when stopped; shows config vs last-started total | +| **Extended RAM fix** | If PROM only POSTs lomem, core **synthesizes MEMCFG** for himem banks 2–3 when configured (`src/mc.rs`) — see [rules/irix/extended-ram-memcfg.md](../rules/irix/extended-ram-memcfg.md) | +| **MHz vs MIPS** | Status-bar **MIPS** = real host speed; IRIX System Manager **MHz** = `hinv` inventory (cosmetic). Debug tab explains build features | + +**Important:** `banks` in config is applied only when the VM **Starts**. Changing RAM in the GUI while IRIX is running updates the saved config, not the live guest — **Stop → change → Start**. + +### MHz vs MIPS (don’t chase the wrong number) + +| Display | Meaning | +|---------|---------| +| System Manager **~166 MHz** | Guest inventory from PROM/kernel — **not** PC emulation speed | +| Status bar **MIPS** | Instructions per wall-clock second on your PC | +| Status bar **Hz** | CP0 Compare tick rate — **not** CPU MHz | + +Enabling JIT raises MIPS; hinv MHz stays the same. That is expected. + +--- + +## Native Windows build (recommended for daily use) + +```powershell +cargo +nightly-x86_64-pc-windows-msvc build --release --bin iris --features lightning,rex-jit,jit,idle-pause +cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release +``` + +Runtime (CLI): + +```powershell +$env:IRIS_JIT = "1" +$env:IRIS_JIT_PROBE = "500" +$env:IRIS_JIT_PROBE_MIN = "100" +$env:IRIS_JIT_MAX_TIER = "2" +.\target\release\iris.exe --config irix-install\iris-windows.toml +``` + +Or use `wsl\run-iris-premiere.bat` (sets env vars automatically). + +Close `iris-gui.exe` before rebuilding if the linker reports “Access is denied”. + +### JIT warm-up (do this before recording) + +Both JITs learn across sessions. **First boot after install is always the slowest.** + +| Profile | Path | Purpose | +|---------|------|---------| +| MIPS JIT | `%USERPROFILE%\.iris\jit-profile.bin` | Hot kernel/userspace blocks | +| REX3 JIT | `%USERPROFILE%\.iris\rex-jit-profile.bin` | Draw-mode shaders | + +**Before recording:** boot IRIX once, open a GL app (`glxgears`, 4Dwm), interact for 5–10 minutes, quit cleanly. The second boot replays saved profiles and is dramatically smoother. + +Monitor (telnet `127.0.0.1:8888`): `perf snapshot`, `rex jit status`, `hal2 status` (cpal underrun counter; codec A dedicated pump). + +**Profile script:** `wsl\profile.ps1` — Task Manager + monitor scrape. + +**CI smoke:** `wsl\smoke-premiere.ps1` — headless iris + `iris-ci ping`. Indigo2 profile: `irix-install\iris-indigo2-smoke-ci.toml`. + +### Phase 3 unified config + +Export from iris-gui includes `[jit]`, `[perf]`, and `[machine]` sections. See [rules/perf/phase3-platform.md](../rules/perf/phase3-platform.md). + +Windows CI default socket: `127.0.0.1:19851` (TCP). Unix: `/tmp/iris.sock`. + +**Warm-up script:** `wsl\warm-jit-profiles.ps1` — launches premiere iris and prints the checklist. + +**A/B recording script:** `wsl\premiere-ab-checklist.ps1` — Take A (slow) vs Take B (premiere CLI) commands. + +### Premiere GUI build + +```powershell +cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release --features premiere +wsl\run-iris-gui-premiere.bat +``` + +The `premiere` feature enables `lightning` + `idle-pause` on the embedded iris core (same stack as CLI premiere, minus native vsync window). + +### Dual-path performance (recording) + +| Path | Best for | +|------|----------| +| `run-iris-premiere.bat` (CLI) | **Max 3D/X11** — native OpenGL + vsync | +| `run-iris-gui-premiere.bat` | In-process demo with JIT + GL capture | +| `run-iris-gui-windows.bat` | Daily config (lighter build) | + +### Optional: GPU capture in iris-gui + +```powershell +$env:IRIS_GUI_GL = "1" +.\target\release\iris-gui.exe +``` + +Uses `GlCompositor` on the refresh thread instead of the CPU path. Still slower than native CLI OpenGL, but better for GUI-only workflows. + +--- + +## Config files + +| File | Role | +|------|------| +| `irix-install/iris-windows.toml` | **Shared canonical TOML** for CLI / premiere.bat (export from GUI to keep in sync) | +| `%APPDATA%\iris\gui.json` | **GUI system of record** — named machines, autosaved edits | +| `irix-install/iris-wsl.toml` | WSL / Linux CLI | + +**Ports (native Windows):** + +- IRIX (NAT forward): `telnet 127.0.0.1 2323` +- Monitor: `telnet 127.0.0.1 8888` +- Serial console: `telnet 127.0.0.1 8881` + +--- + +## RAM layouts + +| Goal | `banks` | Guest RAM (typical) | +|------|---------|---------------------| +| Authentic Indy max | `[128, 128, 0, 0]` | 256 MB | +| IRIX 6.5 extended | `[128, 128, 64, 64]` | 384 MB — use `iris-windows-384.toml` | +| IRIX 5.3 / emulator max | `[128, 128, 128, 128]` | 512 MB — **not for IRIX 6.5** | + +After any change: **Stop → cold Start** (fully quit iris, relaunch). Verify: + +1. Monitor: `mc status` — banks 2–3 should show **VLD=1** when extended RAM is configured +2. IRIX: `hinv -t memory` or System Manager → About This System + +For **IRIX 6.5**, prefer **384 MB** over 512 MB. The 512 MB preset is documented for IRIX 5.3. + +### Silent app quits (debug) + +See [rules/testing/silent-app-quit-debug.md](../rules/testing/silent-app-quit-debug.md). + +1. **JIT A/B:** `wsl\run-iris-premiere-nojit.bat` vs `wsl\run-iris-premiere.bat` +2. **Capture log:** `wsl\capture-app-crash.ps1` (add `-Verify` if JIT-on reproduces the quit) +3. **RAM A/B:** `--config irix-install\iris-windows-384.toml` if you were on 512 MB + +Send `premiere-debug.log`, monitor `status`/`bt`/`dt 80`, and `hinv -t memory` after a quit. + +### Authentic max Indy (R5000SC, 256 MB) + +Compile-time CPU — rebuild **both** CLI and GUI: + +```powershell +cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release --features iris/r5k,iris/r5ksc +cargo +nightly-x86_64-pc-windows-msvc build --release --bin iris --features lightning,rex-jit,jit,idle-pause,iris/r5k,iris/r5ksc +``` + +```toml +banks = [128, 128, 0, 0] +scale = 1 +``` + +Switching CPU after IRIX is installed may require reinstall (same as real hardware). IRIS models R5000SC with 1 MB L2 (real R5000SC often had 512 KB). + +--- + +## GUI ↔ TOML ↔ CI sync + +The GUI and CLI use the same **`MachineConfig`** schema but **different files** unless you sync them. **`iris-ci` loads no config** — it only drives an already-running `iris` process. + +| Layer | Stores | +|-------|--------| +| **iris-gui** | `%APPDATA%\iris\gui.json` | +| **iris CLI** | `iris.toml` via `--config` | +| **JIT env** | **Not in TOML** — GUI Debug tab sets `IRIS_JIT*` at Start; CLI uses bat/shell env | +| **iris-ci** | Nothing (socket client only) | + +### Recommended: one canonical TOML + +Treat **`irix-install/iris-windows.toml`** as the shared file: + +``` +GUI edits → File → Export → iris-windows.toml → premiere.bat / iris.exe + ↑ ↓ + └──────── File → Import when TOML changed on disk ─────┘ +``` + +### Path A — GUI → CLI (after tuning in the UI) + +1. **Stop** the VM. +2. Set RAM, disks, network in the GUI (Memory tab presets, etc.). +3. **File → Export current to iris.toml…** → save as `irix-install/iris-windows.toml`. +4. Run CLI from repo root (paths in TOML are relative to cwd): + + ```powershell + wsl\run-iris-premiere.bat + ``` + +5. Optional: enable **CI mode** on the CI / Automation tab before export if you need `ci = true` in TOML. + +**Export/Import** appear under **File** in source builds (hidden in App Store `bundled` builds). + +### Path B — TOML → GUI + +1. **File → Import iris.toml…** → pick `irix-install/iris-windows.toml`. +2. **Stop → Start** so guest RAM matches imported `banks`. + +### JIT parity (GUI vs CLI) + +Mirror the GUI **Debug / JIT** tab in your launch script: + +```powershell +$env:IRIS_JIT = "1" +$env:IRIS_JIT_PROBE = "500" +$env:IRIS_JIT_PROBE_MIN = "100" +$env:IRIS_JIT_MAX_TIER = "2" +``` + +(`run-iris-premiere.bat` already sets these.) + +### CI (`iris-ci`) + +The CI control socket is **Unix-only** (`#![cfg(unix)]` in `src/ci.rs`). On native Windows, use **WSL** for `iris-ci`; on Windows use **monitor telnet** (`127.0.0.1:8888`) for manual/debug work. + +**WSL workflow:** + +1. TOML includes `ci = true` (and `ci_socket` if non-default). +2. Start iris in WSL with that config. +3. In another WSL terminal: `iris-ci ping`, `iris-ci boot`, etc. + +See [rules/snapshot/iris-ci-is-the-canonical-ci-socket-interface.md](../rules/snapshot/iris-ci-is-the-canonical-ci-socket-interface.md). + +### Sync checklist + +- [ ] Same `banks` in exported TOML and GUI Memory tab +- [ ] Same `nvram`, `scsi.*.path`, `prom` paths (run from repo root or use absolute paths) +- [ ] **Stop → Start** in GUI after RAM changes +- [ ] Same JIT env for CLI as GUI Debug tab +- [ ] `mc status` shows expected MEMCFG for extended RAM +- [ ] Re-export after GUI changes you want CLI/CI to keep + +--- + +## iris-gui first time + +1. Run `run-iris-gui-windows.bat` +2. **File → Import iris.toml…** → `irix-install/iris-windows.toml` (or create a machine and configure) +3. **Memory** tab: pick **256 MB** (authentic) or **384 MB** (IRIX 6.5 extended) +4. **Debug / JIT** tab: enable **IRIS_JIT=1**, probe **500** / min **100** +5. **Start** +6. After further edits: **File → Export** so CLI/premiere.bat stay aligned + +--- + +## From Ubuntu terminal (WSL) + +```bash +cd ~/iris-wsl-build +chmod +x wsl/run-iris.sh wsl/run-iris-gui.sh +./wsl/run-iris.sh # CLI +./wsl/run-iris-gui.sh # GUI +``` + +--- + +## Rebuild + +**Windows (native):** + +```powershell +cargo +nightly-x86_64-pc-windows-msvc build --release --bin iris --features lightning,rex-jit,jit,idle-pause +cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release +``` + +**WSL:** + +```bash +cd ~/iris-wsl-build +cargo build --release --bin iris --features lightning,rex-jit,jit,idle-pause +cargo build -p iris-gui --release +``` + +--- + +## Sync source from Windows → WSL copy + +```bash +rsync -a --exclude target /mnt/c/Users/chron/CURSOR-PROJECTS/iris-main/ ~/iris-wsl-build/ +chmod +x ~/iris-wsl-build/wsl/*.sh +``` + +After syncing, rebuild in WSL if you changed Rust sources. diff --git a/wsl/capture-app-crash.ps1 b/wsl/capture-app-crash.ps1 new file mode 100644 index 0000000..9f18cc3 --- /dev/null +++ b/wsl/capture-app-crash.ps1 @@ -0,0 +1,73 @@ +# Capture host stderr + print checklist when IRIX apps quit silently. +# Usage: +# wsl\capture-app-crash.ps1 # JIT on, default config +# wsl\capture-app-crash.ps1 -NoJit # interpreter A/B +# wsl\capture-app-crash.ps1 -Verify # IRIS_JIT_VERIFY=1 +# wsl\capture-app-crash.ps1 -Config irix-install\iris-windows-384.toml +param( + [string]$Config = "irix-install\iris-windows.toml", + [switch]$NoJit, + [switch]$Verify, + [string]$LogFile = "premiere-debug.log" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +Write-Host "=== IRIS silent-quit capture ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "Before reproducing, note your TOML banks line:" -ForegroundColor Yellow +if (Test-Path $Config) { + Select-String -Path $Config -Pattern "^banks\s*=" | ForEach-Object { Write-Host " $($_.Line)" } +} else { + Write-Host " (config not found: $Config)" -ForegroundColor Red +} +Write-Host "" +Write-Host "Second terminal: telnet 127.0.0.1 8888" -ForegroundColor Yellow +Write-Host "When an app quits, run in monitor:" -ForegroundColor Yellow +Write-Host " stop`n status`n regs`n bt`n dt 80`n cow status`n mc status" +Write-Host "" +Write-Host "In IRIX shell after quit:" -ForegroundColor Yellow +Write-Host " hinv -t memory" +Write-Host " ps -ef | grep -i " +Write-Host " tail -50 /var/adm/SYSLOG" +Write-Host "" +Write-Host "Logging to: $LogFile" -ForegroundColor Green +Write-Host "Press Ctrl+C to stop iris when done." -ForegroundColor Gray +Write-Host "" + +& wsl\ensure-build.bat cli +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$env:IRIS_JIT_PROBE = "500" +$env:IRIS_JIT_PROBE_MIN = "100" +$env:IRIS_JIT_MAX_TIER = "1" + +if ($NoJit) { + $env:IRIS_JIT = "0" + Remove-Item Env:IRIS_JIT_VERIFY -ErrorAction SilentlyContinue + Write-Host "Mode: NO JIT (interpreter only)" -ForegroundColor Magenta +} else { + $env:IRIS_JIT = "1" + if ($Verify) { + $env:IRIS_JIT_VERIFY = "1" + Write-Host "Mode: JIT + VERIFY (slow, catches codegen mismatches)" -ForegroundColor Magenta + } else { + Remove-Item Env:IRIS_JIT_VERIFY -ErrorAction SilentlyContinue + Write-Host "Mode: JIT (premiere default)" -ForegroundColor Magenta + } +} + +$logPath = Join-Path $root $LogFile +$header = @" +=== IRIS capture session $(Get-Date -Format o) === +Config: $Config +NoJit: $NoJit Verify: $Verify +Banks: $(if (Test-Path $Config) { (Select-String -Path $Config -Pattern '^banks\s*=').Line } else { 'n/a' }) +=== +"@ + +$header | Out-File -FilePath $logPath -Encoding utf8 + +& "target\release\iris.exe" --config $Config 2>&1 | Tee-Object -FilePath $logPath -Append diff --git a/wsl/ensure-build.bat b/wsl/ensure-build.bat new file mode 100644 index 0000000..dfcc531 --- /dev/null +++ b/wsl/ensure-build.bat @@ -0,0 +1,39 @@ +@echo off +REM Usage: ensure-build.bat cli|gui +REM Rebuilds when the binary or feature stamp is missing/outdated. +setlocal +cd /d "%~dp0.." + +if /i "%~1"=="cli" goto :cli +if /i "%~1"=="gui" goto :gui +echo Usage: ensure-build.bat cli^|gui +exit /b 1 + +:cli +set BIN=target\release\iris.exe +set STAMP=target\release\.iris-cli-premiere.stamp +set WANT=lightning,rex-jit,jit,idle-pause +set BUILD=cargo +nightly-x86_64-pc-windows-msvc build --release --bin iris --features %WANT% +goto :check + +:gui +set BIN=target\release\iris-gui.exe +set STAMP=target\release\.iris-gui-premiere.stamp +if defined IRIS_GUI_WANT (set WANT=%IRIS_GUI_WANT%) else (set WANT=premiere) +if defined IRIS_GUI_FEATURES (set FEAT=%IRIS_GUI_FEATURES%) else (set FEAT=premiere) +set BUILD=cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release --features %FEAT% +goto :check + +:check +if not exist "%BIN%" goto :build +if not exist "%STAMP%" goto :build +set /p STAMPVAL=<"%STAMP%" +if not "%STAMPVAL%"=="%WANT%" goto :build +exit /b 0 + +:build +echo Building %BIN% (features: %WANT%)... +%BUILD% +if errorlevel 1 exit /b 1 +echo %WANT%> "%STAMP%" +exit /b 0 diff --git a/wsl/premiere-ab-checklist.ps1 b/wsl/premiere-ab-checklist.ps1 new file mode 100644 index 0000000..8489a7f --- /dev/null +++ b/wsl/premiere-ab-checklist.ps1 @@ -0,0 +1,39 @@ +# A/B checklist for YouTube premiere recording (Windows native IRIS). +# Usage: .\wsl\premiere-ab-checklist.ps1 + +Write-Host "" +Write-Host "=== IRIS Premiere A/B Checklist ===" -ForegroundColor Cyan +Write-Host "" + +Write-Host "TAKE A (slow baseline — show interpreter / GUI CPU path)" -ForegroundColor Yellow +Write-Host " Build: cargo build -p iris-gui --release (no premiere feature)" +Write-Host " Run: wsl\run-iris-gui-windows.bat" +Write-Host " JIT: Debug tab -> IRIS_JIT off" +Write-Host " Expect: Low MIPS in status bar; high host CPU at idle desktop" +Write-Host "" + +Write-Host "TAKE B (fast — premiere CLI + warmed profiles)" -ForegroundColor Green +Write-Host " Warm: wsl\warm-jit-profiles.ps1 (once per session)" +Write-Host " Run: wsl\run-iris-premiere.bat" +Write-Host " Config: irix-install\iris-windows.toml (scale=1, export from GUI)" +Write-Host " Expect: 80-150+ MIPS with JIT; lower idle CPU with idle-pause" +Write-Host "" + +Write-Host "TAKE B-alt (in-process GUI with premiere core + GL capture)" -ForegroundColor Green +Write-Host " Run: wsl\run-iris-gui-premiere.bat" +Write-Host " Sets: IRIS_JIT=1, IRIS_GUI_GL=1, lightning+idle-pause build" +Write-Host " Note: Still slower than CLI OpenGL for heavy 3D" +Write-Host "" + +Write-Host "Verify (telnet 127.0.0.1 8888):" -ForegroundColor Cyan +Write-Host " perf snapshot (Phase 3 aggregate: JIT, REX3, HAL2, affinity)" +Write-Host " rex jit status" +Write-Host " hal2 status (cpal underruns should stay low; codec A uses dedicated pump thread)" +Write-Host "" +Write-Host "Benchmark helper: wsl\profile.ps1" +Write-Host "CI smoke: wsl\smoke-premiere.ps1" +Write-Host "" + +Write-Host "GUI workflow:" -ForegroundColor Cyan +Write-Host " File -> Prepare for premiere... (exports TOML + JIT defaults)" +Write-Host "" diff --git a/wsl/profile.ps1 b/wsl/profile.ps1 new file mode 100644 index 0000000..f522759 --- /dev/null +++ b/wsl/profile.ps1 @@ -0,0 +1,85 @@ +# IRIS performance snapshot helper (Windows) +# Usage: .\wsl\profile.ps1 [-IrisExe path] [-MonitorHost 127.0.0.1:8888] [-JsonOut path] + +param( + [string]$IrisExe = "target\release\iris.exe", + [string]$MonitorHost = "127.0.0.1:8888", + [string]$JsonOut = "" +) + +$ErrorActionPreference = "Stop" +$timestamp = Get-Date -Format o + +function Send-Monitor($cmd) { + try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect($MonitorHost.Split(':')[0], [int]$MonitorHost.Split(':')[1]) + $stream = $client.GetStream() + $w = New-Object System.IO.StreamWriter($stream) + $r = New-Object System.IO.StreamReader($stream) + $w.WriteLine($cmd) + $w.Flush() + Start-Sleep -Milliseconds 250 + $out = "" + while ($stream.DataAvailable) { + $line = $r.ReadLine() + if ($null -eq $line) { break } + $out += $line + "`n" + } + $client.Close() + return $out.Trim() + } catch { + return "(monitor unavailable: $_)" + } +} + +Write-Host "=== IRIS profile snapshot ===" -ForegroundColor Cyan +Write-Host "Time: $timestamp" + +$proc = Get-Process iris -ErrorAction SilentlyContinue +$cpuSec = $null +$wsMb = $null +if ($proc) { + $cpuSec = [math]::Round($proc.CPU, 2) + $wsMb = [math]::Round($proc.WorkingSet64/1MB, 1) + Write-Host "`nProcess iris.exe:" + Write-Host " CPU (s): $cpuSec" + Write-Host " WS (MB): $wsMb" +} else { + Write-Host "`niris.exe not running — start premiere bat first." -ForegroundColor Yellow +} + +Write-Host "`n--- monitor: hal2 status ---" +$hal2 = Send-Monitor "hal2 status" +Write-Host $hal2 + +Write-Host "`n--- monitor: rex jit status ---" +$rexJit = Send-Monitor "rex jit status" +Write-Host $rexJit + +Write-Host "`n--- monitor: perf snapshot ---" +$perf = Send-Monitor "perf snapshot" +Write-Host $perf + +$underruns = $null +if ($hal2 -match 'underruns?\s*[=:]\s*(\d+)') { + $underruns = [int]$Matches[1] +} + +$snapshot = [ordered]@{ + timestamp = $timestamp + iris_running = [bool]$proc + cpu_seconds = $cpuSec + working_set_mb = $wsMb + hal2_status = $hal2 + rex_jit_status = $rexJit + perf_snapshot = $perf + cpal_underruns = $underruns +} + +if ($JsonOut) { + $snapshot | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 $JsonOut + Write-Host "`nWrote JSON baseline: $JsonOut" -ForegroundColor Green +} + +Write-Host "`nDone. Compare idle vs glxgears; underruns should stay low under load." -ForegroundColor Green diff --git a/wsl/run-iris-ci.bat b/wsl/run-iris-ci.bat new file mode 100644 index 0000000..89d47e6 --- /dev/null +++ b/wsl/run-iris-ci.bat @@ -0,0 +1,12 @@ +@echo off +setlocal +cd /d "%~dp0.." +call wsl\ensure-build.bat cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=2 +echo Starting iris in CI mode (TCP 127.0.0.1:19851) with irix-install\iris-windows.toml +echo Use: iris-ci ping (after VM is up) +start "iris-ci" /wait target\release\iris.exe --config irix-install\iris-windows.toml diff --git a/wsl/run-iris-gui-premiere.bat b/wsl/run-iris-gui-premiere.bat new file mode 100644 index 0000000..6f5c7e3 --- /dev/null +++ b/wsl/run-iris-gui-premiere.bat @@ -0,0 +1,22 @@ +@echo off +REM iris-gui with premiere embedded core (lightning + idle-pause) + JIT + optional GL capture. +REM Warm JIT profiles first — see wsl\README.md "JIT warm-up". +cd /d "%~dp0.." +set IRIS_GUI_FEATURES=premiere +set IRIS_GUI_STAMP=target\release\.iris-gui-premiere.stamp +set IRIS_GUI_WANT=premiere + +call "%~dp0ensure-build.bat" gui +if errorlevel 1 exit /b 1 + +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=1 +set IRIS_GUI_GL=1 + +echo. +echo Premiere GUI: in-process iris + JIT + OpenGL capture (IRIS_GUI_GL=1). +echo For max 3D recording use run-iris-premiere.bat (native CLI OpenGL). +echo. +start "" "target\release\iris-gui.exe" diff --git a/wsl/run-iris-gui-windows.bat b/wsl/run-iris-gui-windows.bat new file mode 100644 index 0000000..322502b --- /dev/null +++ b/wsl/run-iris-gui-windows.bat @@ -0,0 +1,13 @@ +@echo off +REM Native Windows iris-gui — proper mouse capture and fullscreen. +cd /d "%~dp0.." +if not exist "target\release\iris-gui.exe" ( + echo Building iris-gui first — this takes several minutes... + cargo +nightly-x86_64-pc-windows-msvc build -p iris-gui --release + if errorlevel 1 ( + echo Build failed. + pause + exit /b 1 + ) +) +start "" "target\release\iris-gui.exe" diff --git a/wsl/run-iris-gui.bat b/wsl/run-iris-gui.bat new file mode 100644 index 0000000..e2a6760 --- /dev/null +++ b/wsl/run-iris-gui.bat @@ -0,0 +1,3 @@ +@echo off +REM Double-click or run from cmd: starts iris-gui in WSL (Indy launcher UI). +wsl -d Ubuntu bash -lc "cd ~/iris-wsl-build && ./wsl/run-iris-gui.sh" diff --git a/wsl/run-iris-gui.sh b/wsl/run-iris-gui.sh new file mode 100644 index 0000000..d374dc2 --- /dev/null +++ b/wsl/run-iris-gui.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Launch iris-gui (egui front-end). First run: File → Import iris.toml → +# ~/iris-wsl-build/irix-install/iris-wsl.toml +set -euo pipefail +WSL_ROOT="${IRIS_WSL_ROOT:-$HOME/iris-wsl-build}" +cd "$WSL_ROOT" + +export DISPLAY="${DISPLAY:-:0}" +export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + +exec ./target/release/iris-gui "$@" diff --git a/wsl/run-iris-premiere-full.bat b/wsl/run-iris-premiere-full.bat new file mode 100644 index 0000000..928749b --- /dev/null +++ b/wsl/run-iris-premiere-full.bat @@ -0,0 +1,14 @@ +@echo off +REM Experimental: Full-tier JIT (max_tier=2). Slower on hot kernel loops and may +REM TLBMISS-panic on Windows — use run-iris-premiere.bat (tier 1) for recording. +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=2 +echo. +echo Premiere FULL tier (experimental): may be slow or panic. Prefer run-iris-premiere.bat. +echo. +"target\release\iris.exe" --config irix-install\iris-windows.toml diff --git a/wsl/run-iris-premiere-nojit.bat b/wsl/run-iris-premiere-nojit.bat new file mode 100644 index 0000000..9be2595 --- /dev/null +++ b/wsl/run-iris-premiere-nojit.bat @@ -0,0 +1,11 @@ +@echo off +REM A/B test: interpreter-only (no MIPS JIT). If apps stop quitting, suspect JIT. +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=0 +echo. +echo Premiere NO-JIT: same config as premiere.bat but IRIS_JIT=0 (interpreter only). +echo Launch the same apps for ~10 min and compare stability vs run-iris-premiere.bat +echo. +"target\release\iris.exe" --config irix-install\iris-windows.toml diff --git a/wsl/run-iris-premiere-safe.bat b/wsl/run-iris-premiere-safe.bat new file mode 100644 index 0000000..38543c8 --- /dev/null +++ b/wsl/run-iris-premiere-safe.bat @@ -0,0 +1,17 @@ +@echo off +REM Stable premiere: Loads-tier JIT only (no Full), fresh profile path. +REM Use when premiere.bat panics with TLBMISS KERNEL FAULT at 0xff800000. +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=1 +set IRIS_JIT_PROFILE=jit-profile-safe.bin +echo. +echo Safe premiere: JIT max_tier=Loads (1), isolated profile (no Full-tier replay). +echo If this boots but premiere.bat does not, rename/delete jit-profile.bin in this folder. +echo Monitor: telnet 127.0.0.1 8888 +echo. +"target\release\iris.exe" --config irix-install\iris-windows-safe.toml diff --git a/wsl/run-iris-premiere-verify.bat b/wsl/run-iris-premiere-verify.bat new file mode 100644 index 0000000..e39e0cd --- /dev/null +++ b/wsl/run-iris-premiere-verify.bat @@ -0,0 +1,15 @@ +@echo off +REM JIT verify mode: re-runs each compiled block through interpreter (slow, diagnostic). +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=1 +set IRIS_JIT_VERIFY=1 +echo. +echo Premiere VERIFY: JIT on + IRIS_JIT_VERIFY=1. Watch for "JIT VERIFY FAIL" in log. +echo Use wsl\capture-app-crash.ps1 to tee stderr to premiere-debug.log +echo. +"target\release\iris.exe" --config irix-install\iris-windows.toml diff --git a/wsl/run-iris-premiere.bat b/wsl/run-iris-premiere.bat new file mode 100644 index 0000000..8bb7b7d --- /dev/null +++ b/wsl/run-iris-premiere.bat @@ -0,0 +1,15 @@ +@echo off +REM One-liner for YouTube / recording: full-feature iris CLI, JIT env, premiere config. +REM Warm JIT profiles first — see wsl\README.md "JIT warm-up". +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=1 +echo. +echo Premiere mode: iris CLI + OpenGL + JIT. Click window to grab mouse; Right Ctrl releases. +echo Monitor: telnet 127.0.0.1 8888 ^| rex jit status ^| hal2 status +echo. +"target\release\iris.exe" --config irix-install\iris-windows.toml diff --git a/wsl/run-iris-windows.bat b/wsl/run-iris-windows.bat new file mode 100644 index 0000000..abb32d1 --- /dev/null +++ b/wsl/run-iris-windows.bat @@ -0,0 +1,11 @@ +@echo off +REM Native Windows IRIS CLI — mouse grab: click window, release: Right Ctrl. +REM Full performance stack: lightning + rex-jit + MIPS JIT + idle-pause. +cd /d "%~dp0.." +call "%~dp0ensure-build.bat" cli +if errorlevel 1 exit /b 1 +set IRIS_JIT=1 +set IRIS_JIT_PROBE=500 +set IRIS_JIT_PROBE_MIN=100 +set IRIS_JIT_MAX_TIER=1 +start "" "target\release\iris.exe" --config irix-install\iris-windows.toml diff --git a/wsl/run-iris.bat b/wsl/run-iris.bat new file mode 100644 index 0000000..a073234 --- /dev/null +++ b/wsl/run-iris.bat @@ -0,0 +1,3 @@ +@echo off +REM Double-click or run from cmd: starts IRIS CLI in WSL with your IRIX disk. +wsl -d Ubuntu bash -lc "cd ~/iris-wsl-build && ./wsl/run-iris.sh" diff --git a/wsl/run-iris.sh b/wsl/run-iris.sh new file mode 100644 index 0000000..dbe2a5d --- /dev/null +++ b/wsl/run-iris.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Launch IRIS CLI with the installed IRIX disk (WSLg window). +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WSL_ROOT="${IRIS_WSL_ROOT:-$HOME/iris-wsl-build}" +cd "$WSL_ROOT" + +export DISPLAY="${DISPLAY:-:0}" +export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + +CONFIG="${IRIS_CONFIG:-irix-install/iris-wsl.toml}" +exec ./target/release/iris --config "$CONFIG" "$@" diff --git a/wsl/smoke-premiere.ps1 b/wsl/smoke-premiere.ps1 new file mode 100644 index 0000000..afec60e --- /dev/null +++ b/wsl/smoke-premiere.ps1 @@ -0,0 +1,59 @@ +# Phase 3 smoke test — headless CI with exported TOML (Windows). +# Usage: .\wsl\smoke-premiere.ps1 [-Config path] [-BaselineJson path] +param( + [string]$Config = "irix-install\iris-windows.toml", + [int]$BootTimeoutSec = 120, + [string]$BaselineJson = "target\release\.iris-smoke-baseline.json", + [int]$MaxUnderruns = 50 +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +Write-Host "=== IRIS Phase 3 smoke (premiere TOML) ===" -ForegroundColor Cyan + +if (-not (Test-Path $Config)) { + Write-Error "Missing config: $Config — export from iris-gui first." +} + +& wsl\ensure-build.bat cli +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$env:IRIS_JIT = "1" +$env:IRIS_JIT_PROBE = "500" +$env:IRIS_JIT_PROBE_MIN = "100" +$env:IRIS_JIT_MAX_TIER = "1" + +Write-Host "Starting iris headless CI..." +$iris = Start-Process -FilePath "target\release\iris.exe" -ArgumentList @("--config", $Config) -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 8 + +Write-Host "iris-ci ping..." +& target\release\iris-ci.exe ping +if ($LASTEXITCODE -ne 0) { + Stop-Process -Id $iris.Id -Force -ErrorAction SilentlyContinue + Write-Error "iris-ci ping failed — is ci=true and ci_socket set in TOML?" +} + +Write-Host "perf snapshot..." +$jsonPath = "target\release\.iris-smoke-run.json" +& wsl\profile.ps1 -JsonOut $jsonPath | Out-Null + +if (Test-Path $jsonPath) { + $run = Get-Content $jsonPath -Raw | ConvertFrom-Json + if ($null -ne $run.cpal_underruns -and $run.cpal_underruns -gt $MaxUnderruns) { + Write-Warning "cpal underruns $($run.cpal_underruns) > $MaxUnderruns" + } + if (-not (Test-Path $BaselineJson)) { + Copy-Item $jsonPath $BaselineJson + Write-Host "Saved baseline: $BaselineJson" -ForegroundColor Green + } else { + $base = Get-Content $BaselineJson -Raw | ConvertFrom-Json + Write-Host "Baseline underruns: $($base.cpal_underruns) run: $($run.cpal_underruns)" + } +} + +Write-Host "Stopping iris..." +Stop-Process -Id $iris.Id -Force -ErrorAction SilentlyContinue +Write-Host "Smoke complete." -ForegroundColor Green diff --git a/wsl/summarize-capture.ps1 b/wsl/summarize-capture.ps1 new file mode 100644 index 0000000..91ebf57 --- /dev/null +++ b/wsl/summarize-capture.ps1 @@ -0,0 +1,32 @@ +# Summarize premiere-debug.log after a silent-quit session. +param( + [string]$LogFile = "premiere-debug.log" +) + +$root = Split-Path -Parent $PSScriptRoot +$path = Join-Path $root $LogFile + +if (-not (Test-Path $path)) { + Write-Error "Missing $path — run wsl\capture-app-crash.ps1 first." +} + +Write-Host "=== Log summary: $LogFile ===" -ForegroundColor Cyan +Write-Host "" + +$verify = Select-String -Path $path -Pattern "JIT VERIFY FAIL|REAL CODEGEN MISMATCH" -SimpleMatch:$false +if ($verify) { + Write-Host "JIT VERIFY failures ($($verify.Count)):" -ForegroundColor Red + $verify | Select-Object -Last 10 | ForEach-Object { Write-Host " $($_.Line)" } +} else { + Write-Host "No JIT VERIFY FAIL lines found." -ForegroundColor Green +} + +Write-Host "" +$jitLines = Select-String -Path $path -Pattern "^JIT: \d+ total" | Select-Object -Last 5 +if ($jitLines) { + Write-Host "Last JIT stats lines:" -ForegroundColor Yellow + $jitLines | ForEach-Object { Write-Host " $($_.Line)" } +} + +Write-Host "" +Write-Host "Share this file + monitor stop/status/bt/dt + hinv -t memory if still broken." -ForegroundColor Gray diff --git a/wsl/verify-phase3.ps1 b/wsl/verify-phase3.ps1 new file mode 100644 index 0000000..7cb75d9 --- /dev/null +++ b/wsl/verify-phase3.ps1 @@ -0,0 +1,100 @@ +# Phase 3 verification: start headless CI iris, probe monitor + iris-ci, stop. +param( + [string]$Config = "irix-install\iris-smoke-ci.toml", + [int]$WarmupSec = 8 +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +function Send-Monitor($cmd, [int]$timeoutSec = 5) { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect("127.0.0.1", 8888) + $stream = $client.GetStream() + $writer = New-Object System.IO.StreamWriter($stream) + $writer.NewLine = "`n" + $reader = New-Object System.IO.StreamReader($stream) + $buf = New-Object System.Text.StringBuilder + $deadline = (Get-Date).AddSeconds($timeoutSec) + # Read until initial prompt + while ((Get-Date) -lt $deadline -and -not ($buf.ToString() -match "> `r?`n$")) { + if ($stream.DataAvailable) { + $line = $reader.ReadLine() + if ($null -eq $line) { break } + [void]$buf.AppendLine($line) + } else { Start-Sleep -Milliseconds 30 } + } + $writer.WriteLine($cmd) + $writer.Flush() + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + if ($stream.DataAvailable) { + $line = $reader.ReadLine() + if ($null -eq $line) { break } + [void]$buf.AppendLine($line) + if ($line.Trim() -eq ">") { break } + } else { Start-Sleep -Milliseconds 30 } + } + $client.Close() + # Strip ASCII art banner; keep command output + $text = $buf.ToString() + $idx = $text.LastIndexOf("Monitor") + if ($idx -ge 0) { + $text = $text.Substring($idx) + } + return $text.Trim() +} + +Write-Host "=== Phase 3 verify ===" -ForegroundColor Cyan +if (-not (Test-Path $Config)) { throw "Missing $Config" } +if (-not (Test-Path "target\release\iris.exe")) { throw "Build iris first" } +if (-not (Test-Path "target\release\iris-ci.exe")) { throw "Build iris-ci first" } + +$env:IRIS_JIT = "1" +$env:IRIS_JIT_PROBE = "500" +$env:IRIS_JIT_PROBE_MIN = "100" +$env:IRIS_JIT_MAX_TIER = "2" + +$logOut = Join-Path $env:TEMP "iris-smoke-out.log" +$logErr = Join-Path $env:TEMP "iris-smoke-err.log" +Write-Host "Starting iris (logs: $logOut)..." +$iris = Start-Process -FilePath "target\release\iris.exe" ` + -ArgumentList @("--config", $Config) ` + -RedirectStandardOutput $logOut ` + -RedirectStandardError $logErr ` + -PassThru -WindowStyle Hidden + +try { + Write-Host "Warmup ${WarmupSec}s..." + Start-Sleep -Seconds $WarmupSec + + if ($iris.HasExited) { + Write-Host "--- iris stderr ---" -ForegroundColor Red + Get-Content $logErr -ErrorAction SilentlyContinue | Select-Object -Last 40 + Write-Host "--- iris stdout ---" -ForegroundColor Red + Get-Content $logOut -ErrorAction SilentlyContinue | Select-Object -Last 20 + throw "iris exited early (code $($iris.ExitCode))" + } + + Write-Host "`n--- iris-ci ping ---" -ForegroundColor Yellow + $ping = & target\release\iris-ci.exe ping 2>&1 + $ping | Write-Host + if ($LASTEXITCODE -ne 0) { throw "iris-ci ping failed ($LASTEXITCODE)" } + + Write-Host "`n--- monitor: perf snapshot ---" -ForegroundColor Yellow + Send-Monitor "perf snapshot" | Write-Host + + Write-Host "`n--- monitor: hal2 status ---" -ForegroundColor Yellow + Send-Monitor "hal2 status" | Write-Host + + Write-Host "`n(note: headless CI skips REX3; use premiere CLI/GUI for rex jit status)" -ForegroundColor DarkGray + + Write-Host "`nPASS: CI socket + monitor commands OK" -ForegroundColor Green +} finally { + if (-not $iris.HasExited) { + Write-Host "Stopping iris (pid $($iris.Id))..." + Stop-Process -Id $iris.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + } +} diff --git a/wsl/warm-jit-profiles.ps1 b/wsl/warm-jit-profiles.ps1 new file mode 100644 index 0000000..f7d1426 --- /dev/null +++ b/wsl/warm-jit-profiles.ps1 @@ -0,0 +1,25 @@ +# Warm MIPS + REX3 JIT profiles before recording. +# Usage: .\wsl\warm-jit-profiles.ps1 +# Requires: iris built (run-iris-premiere.bat builds if needed). + +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "..") + +Write-Host "" +Write-Host "=== IRIS JIT warm-up ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "This script launches premiere iris.exe with your config." +Write-Host "After IRIX boots:" +Write-Host " 1. Log in (telnet 127.0.0.1 2323 if needed)" +Write-Host " 2. Open a GL app (glxgears, 4Dwm, a demo)" +Write-Host " 3. Interact for 5-10 minutes" +Write-Host " 4. Quit iris cleanly (monitor: quit, or close window)" +Write-Host "" +Write-Host "Profiles saved under:" +Write-Host " $env:USERPROFILE\.iris\jit-profile.bin" +Write-Host " $env:USERPROFILE\.iris\rex-jit-profile.bin" +Write-Host "" +Write-Host "Monitor (optional): telnet 127.0.0.1 8888 -> rex jit status" +Write-Host "" + +& (Join-Path $PSScriptRoot "run-iris-premiere.bat")