Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ hashes are given where a change is easiest to understand by reading the commit.

## November 2026

### Terminal console

- The terminal is left in a usable state when iris ends from the monitor's `quit`, Ctrl-C, a signal, the guest powering off, the window closing, or a panic. Before, all of these except Ctrl-C could leave the shell with no echo and no line editing until `stty sane`. All machines, Linux, macOS and Windows.

### VPro textures

- Textures sgiquake1 uploads in 16-bit formats (RGBA4, RGB5_A1) show again
instead of noise; broken since `fa2c91c9` (2026-11-11). All VPro boards.
- Texture and image uploads from `GL_UNSIGNED_SHORT_5_6_5` data and with
`GL_UNPACK_SWAP_BYTES` set come out in the right colours; piglit's
`texture-packed-formats` passes.

### Serial logs append on every machine

- New `serial_log_overwrite` (`--serial-log-overwrite`) starts `serial_log`, `serial_log_a` and `serial_log_b` over each run instead of adding to them.
Expand Down
11 changes: 7 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ nokhwa = { version = "0.10", features = ["input-msmf"] }
# host_dns.rs reads the host's DNS servers with GetAdaptersAddresses
# (Win32_NetworkManagement_IpHelper); IP_ADAPTER_ADDRESSES_LH is gated behind
# Win32_NetworkManagement_Ndis and Win32_Networking_WinSock.
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_SystemInformation", "Win32_Security", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Media"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_SystemInformation", "Win32_Security", "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Media", "Win32_System_Console"] }

[target.'cfg(not(windows))'.dependencies]
libc = "0.2"
Expand Down Expand Up @@ -421,14 +421,17 @@ codegen-units = 1
panic = "abort"
debug = 1

# Developer profile: release optimizations + debug symbols.
# Developer profile: release optimizations + debug symbols, but thin LTO and
# 16 codegen units. Fat LTO is one serial LLVM job (~9 min per edit on 64
# cores); thin LTO runs in parallel (1-2.5 min) and costs ~4% guest speed.
# Measure with --release, not this profile.
# Profiles do not enable Cargo features. Select the developer feature explicitly
# for dev-only tooling: cargo build --profile developer --features developer.
[profile.developer]
inherits = "release"
debug = true
lto = "fat"
codegen-units = 1
lto = "thin"
codegen-units = 16


# Profiling profile: release optimizations + full debug info for flamegraph/perf.
Expand Down
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ packaging still impose the host restrictions described above.
|---|---|---|
| `dev` | `cargo build` / `cargo run` | Cargo development defaults: unoptimized, with debug information. |
| `release` | `--release` | Optimization level 3, fat LTO, one codegen unit, abort on panic, debug level 1. |
| `developer` | `--profile developer` | Inherits release optimization; full debug information. Enable `--features developer` separately for instrumentation. |
| `developer` | `--profile developer` | Optimization level 3 with thin LTO and 16 codegen units, so rebuilds after an edit take one to three minutes instead of about nine; runs about 4% slower than `release`. Full debug information. Enable `--features developer` separately for instrumentation. |
| `profiling` | `--profile profiling` | Inherits release optimization; full debug information for profiling. |

[iris-gui/build.rs](iris-gui/build.rs) reads `RELEASE_VERSION` at build time
Expand Down
1 change: 1 addition & 0 deletions clanker/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ Odyssey (VPro V6/V8/V10/V12): the Octane2/Fuel/Tezro graphics board. Modelled on
| [lanes-field-view-is-eight-bytes.md](odyssey/lanes-field-view-is-eight-bytes.md) | rule | Window moves copy 16-byte pixels as two 8-byte halves; a lanes field's view length must be 8, not 6 (ghost text in title bars) |
| [gl-reads-and-copies-as-traced.md](odyssey/gl-reads-and-copies-as-traced.md) | rule | What the GL library sends for glReadPixels (a DMA get; with transfer, a filtered copy to scratch first), glCopyPixels (one copy inside an image at zoom +-1, scratch and a textured image otherwise, depth and stencil through the host) and glCopyTexImage (a copy into the level, y reversed); the copy's walk bits against the image box give the mirror; copies run as the GPU unit's kernel |
| [screen-tiles.md](odyssey/screen-tiles.md) | design | The fragment pipeline on screen tiles: one command per primitive, its index on every tile it touches, one owner per tile walks, shades and stores in order; bit-exact; Quake replay 86 s (three stages) to 18 s (64-pixel tiles, 16 workers) |
| [texture-upload-words-traced-2026-11-12.md](odyssey/texture-upload-words-traced-2026-11-12.md) | rule | a DMA upload's word 4 can name a field wider than its pixel (sgiquake1 RGBA4/RGB5_A1 levels): the selected level's packing applies; image header codes per GL packed type, bit 10 = swap bytes, 5:6:5 under types 0xa and 0xc; the gamma ramp changes per session, compare unit screenshots under one ramp |
| [quake-gpu-unit-profile-2026-11-07.md](odyssey/quake-gpu-unit-profile-2026-11-07.md) | measure | sgiquake1 keeps a never-clipping user plane on, which sent every triangle to CPU shading (fixed exactly by `user_side`); the raster kernel then went 168 -> 96 VGPRs (one call site per stage, quad exchange, a tap loop that cannot unroll): 12.9 -> 8.2 s; how to measure registers; a destination tile cache in LDS measured slower (ceiling 4%, +17% after its register fixes); per-lane values held across hooks cost VGPRs; coarse list binning: bin 1.07 -> 0.20 s, raster +4% unexplained; binning by edges/spans (VPro and IMPACT): prims per busy tile 10.1 -> 4.9 and 3.6 -> 1.3, bin+raster -13%/-12% (-17% at a pinned GPU clock; unpinned times ran near 1.2 of 2.6 GHz); live sgiquake timedemo on IP28 27 fps, guest-CPU-bound |
| [quake-replay-is-fragment-bound.md](odyssey/quake-replay-is-fragment-bound.md) | measure | Whole Quake replay at 2 workers: ~550 cycles per textured fragment (texture 48%), front end waits most of the run, fences moved onto the ring (no wall change); quads as lanes plus fearless_simd kernels took the full replay from ~193 s to 152 s; unpinned workers on the 2990WX cost 60% more CPU across dies |
| [a-refused-command-store-answers-busy-never-spins.md](odyssey/a-refused-command-store-answers-busy-never-spins.md) | rule | RAD "thread held off" under Quake: the room check counted retired stores while the GFifo head is published lazily, so `write_command` spun on the host with no interrupts; check the ring's own view and answer busy |
Expand Down
56 changes: 56 additions & 0 deletions clanker/odyssey/texture-upload-words-traced-2026-11-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Texture uploads: word 4's field size, the image header's swap and type bits

Status: rule, 2026-11-12 (IRIX 6.5.30, V12 model, `ignore/ip30-ody`).

## A DMA upload's word 4 can name a field wider than its pixel

- Symptom: sgiquake1 (`/usr/games/sgiquake1/quake.gl.r10k`) showed its HUD,
weapon and most world textures as noise on both raster units, from
`fa2c91c9` (2026-11-11) on.
- Its texture levels are RGBA4 and RGB5_A1 (2-byte texels). The library
selects the level (select words base, pitch, `0x08000060`) and uploads
4-byte RGBA or ABGR pixels by DMA with word 4 `0x80003000`: bits 15:13
name a 4-byte field, bits 19:16 a 2-byte pixel.
- `fa2c91c9` took the selected mode's packing only when its field size
equals word 4's (for 4Dwm's icons: word 4 `0x8223305a`, a 4-byte field in
the 16-byte frame-buffer pixel, after a stale index select). The texture
uploads then packed 4-byte fields into 2-byte texels.
- Rule (`Board::dma_fields`): the selected mode's packing applies when its
field has word 4's size, or when word 4's field does not fit word 4's
own pixel. Test: `a_texture_upload_naming_wider_fields_than_its_pixel_packs_as_the_level`.

## Image header (FIFO) codes: bits 8:0 and bit 10

Traced with piglit's `texture-packed-formats` (format word low bits =
format, bits 8:5 = type, `pixel_write.rs`):

| GL type | code |
|---|---|
| RGBA, UNSIGNED_INT_8_8_8_8 / BYTE | 0x4b |
| RGBA, 8_8_8_8_REV | 0x4c |
| BGRA, 8_8_8_8 / 8_8_8_8_REV | 0x51 / 0x50 |
| RGBA 10_10_10_2 / 2_10_10_10_REV | 0x10b / 0x10c |
| 4_4_4_4 / _REV (RGBA, BGRA) | 0xcb 0xcc / 0xd1 0xd0 |
| 5_5_5_1 / 1_5_5_5_REV | 0xeb 0xec / 0xf1 0xf0 |
| RGB, BGR 5_6_5 | 0x149, 0x14d **or** 0x189, 0x18d, by internal format |
| 3_3_2 / 2_3_3_REV | 0xa9 / 0xad |

- `GL_UNPACK_SWAP_BYTES` sets header bit 10 (`image::HEADER_SWAP`) and
leaves the code alone. The model reverses each element (a 16/32-bit
component, else the whole pixel: 8_8_8_8 arrives as 0x4b), INFERRED from
GL; the board's own swap is unseen.
- `image.code` is the header's low byte. 0x149's low byte 0x49 is the RGB
byte code, so a type-8+ code that the write table knows must skip the
byte-stream path (`image_burst_word`).
- Unit tests in `pixel_write.rs`. The DMA path's word 7 has no swap bit
seen yet.

## Comparing the two raster units by screenshot

- The gamma ramp (colour RAM 0x1a00..) changes during a session (a fresh
boot had 0/133/200 at entries 0/0x80/0xff, a later session 44/188/252):
screenshots from two sessions differ everywhere. Read `vpro lut 0x1a00 1`
etc. before and after a sweep and compare only under the same ramp.
- Same ramp, final build: all 101 glprim texture shots (32 sized formats x
8x8, 64x64, 256x32, plus mipmapped and linear cases) were pixel-identical
on the CPU and GPU units.
88 changes: 88 additions & 0 deletions src/console_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,91 @@ pub fn history_path() -> Option<PathBuf> {
history_path_with(std::env::consts::OS, |n| std::env::var_os(n))
}

/// The terminal's settings as they were before the line editor first took it
/// over, so any exit can put them back.
///
/// The editor switches the terminal to raw mode for each `readline` and
/// restores it when the call returns or the editor is dropped. The process
/// usually ends from another thread while `readline` is still waiting (the
/// monitor's `quit`, a signal, the guest powering off, the window closing), so
/// neither happens, and the shell inherits a terminal with no echo and no line
/// editing. [`restore_terminal`] runs on every one of those paths.
#[cfg(unix)]
mod tty {
use std::sync::OnceLock;

static SAVED: OnceLock<libc::termios> = OnceLock::new();

pub fn save() {
if SAVED.get().is_some() {
return;
}
let mut t = std::mem::MaybeUninit::<libc::termios>::uninit();
// SAFETY: tcgetattr fills `t` when it returns 0.
if unsafe { libc::tcgetattr(libc::STDIN_FILENO, t.as_mut_ptr()) } == 0 {
let _ = SAVED.set(unsafe { t.assume_init() });
}
}

pub fn restore() {
if let Some(t) = SAVED.get() {
// SAFETY: `t` is a termios tcgetattr filled in for this terminal.
unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, t) };
}
}
}

#[cfg(windows)]
mod tty {
use std::sync::OnceLock;
use windows_sys::Win32::System::Console::{
GetConsoleMode, GetStdHandle, SetConsoleMode, CONSOLE_MODE, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
};

static SAVED: OnceLock<[Option<CONSOLE_MODE>; 2]> = OnceLock::new();
const HANDLES: [STD_HANDLE; 2] = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE];

pub fn save() {
let _ = SAVED.get_or_init(|| {
HANDLES.map(|h| {
let mut mode: CONSOLE_MODE = 0;
// SAFETY: plain console API calls on this process's handles.
(unsafe { GetConsoleMode(GetStdHandle(h), &mut mode) } != 0).then_some(mode)
})
});
}

pub fn restore() {
if let Some(saved) = SAVED.get() {
for (h, mode) in HANDLES.iter().zip(saved) {
if let Some(mode) = mode {
// SAFETY: as above.
unsafe { SetConsoleMode(GetStdHandle(*h), *mode) };
}
}
}
}
}

#[cfg(not(any(unix, windows)))]
mod tty {
pub fn save() {}
pub fn restore() {}
}

/// Remember the terminal's settings, once, before the line editor changes
/// them. Later calls keep the first copy.
pub fn save_terminal() {
tty::save();
}

/// Put the terminal back as [`save_terminal`] found it. Does nothing if it was
/// never saved (no line editor, or stdin is not a terminal); safe to call more
/// than once and from any thread.
pub fn restore_terminal() {
tty::restore();
}

#[cfg(feature = "lineedit")]
pub use editor::LineEditor;

Expand Down Expand Up @@ -181,6 +266,9 @@ mod editor {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return None;
}
// Before the editor first switches to raw mode, so every exit
// path can restore the terminal (`restore_terminal`).
save_terminal();
let path = history_path();
let mut ed = editor_with_history(path.as_deref()).ok()?;
let printer = Box::new(ed.create_external_printer().ok()?);
Expand Down
5 changes: 5 additions & 0 deletions src/crash_diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ fn install_panic_hook() {
return;
}

// A release build aborts on panic, skipping every Drop: give the
// shell its terminal back from the console's raw mode first, which
// also keeps the report below readable.
crate::console_edit::restore_terminal();

let thread = std::thread::current();
let bt = std::backtrace::Backtrace::force_capture();
let report = format!(
Expand Down
4 changes: 4 additions & 0 deletions src/dev/odyssey/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ pub(super) struct ImageRaster {
pub code: u32,
/// The header's whole write code, format and type (write::CODE_MASK).
pub write_code: u32,
/// The header's byte-swap bit (image::HEADER_SWAP): GL_UNPACK_SWAP_BYTES.
pub swap: bool,
/// The bytes of the pixel a write-table image is part way through.
pub pixel: [u8; 16],
pub bytes: u32,
Expand Down Expand Up @@ -756,6 +758,7 @@ impl Default for Board {
group_painted: 0,
code: 0,
write_code: 0,
swap: false,
pixel: [0; 16],
bytes: 0,
nbytes: 0,
Expand Down Expand Up @@ -1100,6 +1103,7 @@ impl Board {
self.image.group_painted = 0;
self.image.code = 0;
self.image.write_code = 0;
self.image.swap = false;
self.image.bytes = 0;
self.image.nbytes = 0;
self.diag.image_code_reported = false;
Expand Down
1 change: 1 addition & 0 deletions src/dev/odyssey/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ impl Board {
self.image.group_painted = 0;
self.image.code = self.cmd.operand[0] & image::CODE_MASK;
self.image.write_code = self.cmd.operand[0] & write::CODE_MASK;
self.image.swap = self.cmd.operand[0] & image::HEADER_SWAP != 0;
self.image.nbytes = 0;
self.image.bytes = 0;
}
Expand Down
5 changes: 5 additions & 0 deletions src/dev/odyssey/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,11 @@ pub(super) mod image {
pub const CODE_RGB12: u32 = 0x69;
pub const CODE_ABGR12: u32 = 0x6c;

/// The transfer header's byte swap (GL_UNPACK_SWAP_BYTES): the library
/// sets bit 10 for every packed and multi-byte type under it (traced:
/// piglit's texture-packed-formats), and leaves the code as it is.
pub const HEADER_SWAP: u32 = 1 << 10;

// The transfer header's first-burst count, bits 23:20, 15 standing for 16.
pub const HEADER_GROUP_SHIFT: u32 = 20;
pub const HEADER_GROUP_MASK: u32 = 0xf;
Expand Down
10 changes: 8 additions & 2 deletions src/dev/odyssey/dma.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,13 @@ impl Board {
// to it) and the mode's field there is word 4's size, else what word
// 4's code and class name. The X server puts a TrueColor window's
// image straight after drawing in an index window, with the index
// mode still selected (traced: 4Dwm's icons).
// mode still selected (traced: 4Dwm's icons): its word 4 names
// 4-byte fields in the 16-byte frame-buffer pixel. A texture level
// upload's word 4 can name a field wider than its own pixel
// (traced: sgiquake1's RGBA4 and RGB5_A1 levels, word 4 0x80003000,
// 4-byte fields in a 2-byte pixel): that size is not the level's
// storage, and the selected level's packing applies.
let fits = (bytes as u32) <= 1u32 << (((fmt >> 16) & 0xf) + 1);
let mode = Format::decode(self.pixel_mode[2]);
let layout = super::field::Layout::of(mode);
let selected = !drawable && addr == self.pixel_mode[0] & ADDR_MASK;
Expand All @@ -212,7 +218,7 @@ impl Board {
if drawable && read {
return self.drawable_codec(origin.0, origin.1, at).filter(|c| super::copy::codec_bytes(*c) == bytes).unwrap_or(named);
}
match [layout.front, layout.back].into_iter().find(|f| selected && f.at == at && super::copy::codec_bytes(f.codec) == bytes) {
match [layout.front, layout.back].into_iter().find(|f| selected && f.at == at && (super::copy::codec_bytes(f.codec) == bytes || !fits)) {
Some(f) => f.codec,
None => named,
}
Expand Down
12 changes: 10 additions & 2 deletions src/dev/odyssey/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1108,7 +1108,12 @@ impl Board {
/// 0x00RRGGBB pixel a word and 0x10 four indices, and is reported once.
pub fn image_burst_word(&mut self, word: u32) {
let mut code = self.image.code;
let mut n = pixel_bytes(code);
// Swapped pixels go through the write table, which swaps them; so
// does a code of type 8 or more, whose low byte alone can name a
// byte format (UNSIGNED_SHORT_5_6_5 is 0x149 under RGB, traced,
// and 0x49 is RGB bytes).
let wide = self.image.write_code & 0x100 != 0 && write_form(self.image.write_code).is_some();
let mut n = if self.image.swap || wide { 0 } else { pixel_bytes(code) };
let mut b = [0u8; 4];

if !self.image_burst_supported(self.image.fmt) {
Expand Down Expand Up @@ -1202,8 +1207,11 @@ impl Board {
continue;
}
self.image.nbytes = 0;
let px = self.image.pixel;
let mut px = self.image.pixel;
let row = self.image.cur_y;
if self.image.swap {
f.swap_bytes(&mut px);
}

self.image_texel_form(f, &px);
if i < 3 && self.image.cur_y != row {
Expand Down
Loading
Loading