diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fae8cf01..e021b3221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index 6150809b4..2fdb65dc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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. diff --git a/FEATURES.md b/FEATURES.md index 1c1c8eba5..cae18977b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/clanker/INDEX.md b/clanker/INDEX.md index f3b2208f3..b3e55f016 100644 --- a/clanker/INDEX.md +++ b/clanker/INDEX.md @@ -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 | diff --git a/clanker/odyssey/texture-upload-words-traced-2026-11-12.md b/clanker/odyssey/texture-upload-words-traced-2026-11-12.md new file mode 100644 index 000000000..cf0f70b4d --- /dev/null +++ b/clanker/odyssey/texture-upload-words-traced-2026-11-12.md @@ -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. diff --git a/src/console_edit.rs b/src/console_edit.rs index 9ad1f9782..53477d6e4 100644 --- a/src/console_edit.rs +++ b/src/console_edit.rs @@ -78,6 +78,91 @@ pub fn history_path() -> Option { 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 = OnceLock::new(); + + pub fn save() { + if SAVED.get().is_some() { + return; + } + let mut t = std::mem::MaybeUninit::::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; 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; @@ -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()?); diff --git a/src/crash_diag.rs b/src/crash_diag.rs index 8e6ee4d6f..9d638b82e 100644 --- a/src/crash_diag.rs +++ b/src/crash_diag.rs @@ -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!( diff --git a/src/dev/odyssey/board.rs b/src/dev/odyssey/board.rs index 058d8d884..8ceb299db 100644 --- a/src/dev/odyssey/board.rs +++ b/src/dev/odyssey/board.rs @@ -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, @@ -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, @@ -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; diff --git a/src/dev/odyssey/command.rs b/src/dev/odyssey/command.rs index a48f9a4ac..3003441c8 100644 --- a/src/dev/odyssey/command.rs +++ b/src/dev/odyssey/command.rs @@ -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; } diff --git a/src/dev/odyssey/consts.rs b/src/dev/odyssey/consts.rs index c79b996d0..82782a3f4 100644 --- a/src/dev/odyssey/consts.rs +++ b/src/dev/odyssey/consts.rs @@ -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; diff --git a/src/dev/odyssey/dma.rs b/src/dev/odyssey/dma.rs index 7e10713a6..9aa6fe839 100644 --- a/src/dev/odyssey/dma.rs +++ b/src/dev/odyssey/dma.rs @@ -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; @@ -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, } diff --git a/src/dev/odyssey/draw.rs b/src/dev/odyssey/draw.rs index d7458ebfb..fb8b21945 100644 --- a/src/dev/odyssey/draw.rs +++ b/src/dev/odyssey/draw.rs @@ -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) { @@ -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 { diff --git a/src/dev/odyssey/pixel_write.rs b/src/dev/odyssey/pixel_write.rs index 0680d5d89..4937394dc 100644 --- a/src/dev/odyssey/pixel_write.rs +++ b/src/dev/odyssey/pixel_write.rs @@ -76,12 +76,18 @@ pub(super) fn write_form(code: u32) -> Option { 0xb => &[Red, Green, Blue, Alpha], 0xc => &[Alpha, Blue, Green, Red], 0xd => &[Blue, Green, Red], + // BGRA, UNSIGNED_INT_8_8_8_8_REV in memory (traced: code 0x50). + 0x10 => &[Alpha, Red, Green, Blue], 0x11 => &[Blue, Green, Red, Alpha], _ => &[], }; // Whole components: type 2 bytes, 3 shorts, 4 words; 0xb and 0xc are - // bytes written into a 12-bit or an 8-bit RGBA drawable. + // bytes written into a 12-bit or an 8-bit RGBA drawable. Type 0xc under + // a three-component format is UNSIGNED_SHORT_5_6_5, as type 0xa is + // (traced: 0x149, 0x14d, 0x189 and 0x18d for RGB and BGR by the internal + // format, piglit's texture-packed-formats). let comp_bytes = match ty { + 0xc if comps.len() == 3 => 0, 2 | 0xb | 0xc => 1, 3 => 2, 4 => 4, @@ -109,7 +115,7 @@ pub(super) fn write_form(code: u32) -> Option { (5, 3) => { if order[0] == Blue { &[2, 3, 3] } else { &[3, 3, 2] } } - (0xa, 3) => &[5, 6, 5], + (0xa | 0xc, 3) => &[5, 6, 5], (6, 4) => &[4, 4, 4, 4], (7, 4) => { if order[0] == Alpha { &[1, 5, 5, 5] } else { &[5, 5, 5, 1] } @@ -139,6 +145,21 @@ fn scale8(v: u64, bits: u32) -> u32 { } impl WriteForm { + /// Undo GL_UNPACK_SWAP_BYTES on the pixel in `px`: the bytes of each + /// element reversed, an element a component of 16 or 32 bits, else the + /// whole pixel (a packed word, or UNSIGNED_INT_8_8_8_8, which the + /// library sends as bytes). INFERRED from GL's definition; how the board + /// swaps is not known. + pub fn swap_bytes(&self, px: &mut [u8]) { + let unit = match self.kind { + WriteKind::Comps { comp_bytes, .. } if comp_bytes > 1 => comp_bytes as usize, + _ => self.bytes as usize, + }; + for e in px[..self.bytes as usize].chunks_mut(unit.max(1)) { + e.reverse(); + } + } + /// Whether a pixel of this form carries an alpha (or intensity) value the /// board stores, rather than the model's 255. pub fn has_alpha(&self) -> bool { @@ -215,3 +236,46 @@ impl WriteForm { (r << 24) | (g << 16) | (bl << 8) | a } } + +#[cfg(test)] +mod tests { + use super::*; + + /// UNSIGNED_SHORT_5_6_5 arrives as type 0xa (0x149, 0x14d) or 0xc + /// (0x189, 0x18d) under RGB and BGR, by the internal format, two bytes + /// a pixel. + #[test] + fn five_six_five_is_type_twelve_under_rgb_and_bgr() { + let rgb = write_form(0x189).expect("RGB 5_6_5"); + assert_eq!(rgb.bytes, 2); + assert_eq!(rgb.decode(&[0xf8, 0x00]), 0xff00_00ff); + assert_eq!(rgb.decode(&[0x07, 0xe0]), 0x00ff_00ff); + assert_eq!(write_form(0x149).expect("RGB 5_6_5, type 0xa").decode(&[0xf8, 0x00]), 0xff00_00ff); + let bgr = write_form(0x18d).expect("BGR 5_6_5"); + assert_eq!(bgr.decode(&[0xf8, 0x00]), 0x0000_ffff); + // Type 0xc under RGBA stays a byte write. + assert_eq!(write_form(0x18b).expect("RGBA bytes").bytes, 4); + } + + /// GL_UNPACK_SWAP_BYTES reverses each element: a packed pixel whole, a + /// 16-bit component on its own. + #[test] + fn swapping_reverses_each_element() { + let f = write_form(0xcb).expect("RGBA 4_4_4_4"); + let mut px = [0x08, 0xf0]; + f.swap_bytes(&mut px); + assert_eq!(f.decode(&px), 0xff00_0088); + let f = write_form(0x4b).expect("RGBA bytes, or UNSIGNED_INT_8_8_8_8"); + let mut px = [0x80, 0x00, 0x00, 0xff]; + f.swap_bytes(&mut px); + assert_eq!(f.decode(&px), 0xff00_0080); + let f = write_form(0x50).expect("BGRA, UNSIGNED_INT_8_8_8_8_REV"); + let mut px = [0x00, 0x00, 0xff, 0x80]; + f.swap_bytes(&mut px); + assert_eq!(f.decode(&px), 0xff00_0080, "red, alpha 0x80"); + let f = write_form(0x6b).expect("RGBA shorts"); + let mut px = [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff]; + f.swap_bytes(&mut px); + assert_eq!(f.decode(&px), 0x8000_00ff, "red 0x8000 after the swap"); + } +} diff --git a/src/dev/odyssey/tests/pixels.rs b/src/dev/odyssey/tests/pixels.rs index 3a5278a63..0be09e433 100644 --- a/src/dev/odyssey/tests/pixels.rs +++ b/src/dev/odyssey/tests/pixels.rs @@ -168,17 +168,39 @@ fn an_x_server_put_after_an_index_select_keeps_its_own_field_size() { assert_eq!(rows.load_word(210, 16), Some(0x1b0d_31ff)); } +/// sgiquake1's texture uploads, traced on the V12: the library selects an +/// RGB5_A1 level (select words 0x16b800, 8, 0x08000060: 2-byte texels) +/// and puts 4-byte A, B, G, R pixels into it by DMA under word 4 +/// 0x80003000, which names 4-byte fields in a 2-byte pixel. The level's own +/// packing applies: packed as a 4-byte field, each texel spilled into the +/// next and the game's textures turned to noise. +#[test] +fn a_texture_upload_naming_wider_fields_than_its_pixel_packs_as_the_level() { + let (mut s, ram) = window(); + put_one(&mut s, &ram, (0x16b800, 8, 0x0800_0060), 0x8000_3000, 0x3a01_004c, (0, 0), [0xff, 0x08, 0x80, 0xf8]); + let level = scratch(&s, 0x16b800, 8, 1, Codec::Texel { bytes: 2, field: 6 }); + assert_eq!(level.load_word(0, 0), Some(0xff84_08ff), "R 31, G 16, B 1, A 1 as 5:5:5:1"); + assert_eq!(level.load_word(1, 0), Some(0), "the next texel untouched"); +} + /// The X server's one-pixel 24-bit put at (x, y) by DMA, A, B, G, R bytes /// `abgr` under code 0x4c and word 4 `word4`, after it selected the screen /// with third word `mode`. fn x_server_put(s: &mut Board, ram: &Ram, mode: u32, word4: u32, (x, y): (u32, u32), abgr: [u8; 4]) { - for w in [select(reg::DEST_SELECT), 0x2000, 0x50, mode, 0] { + put_one(s, ram, (0x2000, 0x50, mode), word4, 0x3201_004c, (x, y), abgr); +} + +/// A one-pixel DMA put of A, B, G, R bytes `abgr` at (x, y) of the surface +/// at `base` with `pitch` tiles a row, after selecting it with third word +/// `mode`, under word 4 `word4` and word 7 `word7`. +fn put_one(s: &mut Board, ram: &Ram, (base, pitch, mode): (u32, u32, u32), word4: u32, word7: u32, (x, y): (u32, u32), abgr: [u8; 4]) { + for w in [select(reg::DEST_SELECT), base, pitch, mode, 0] { s.mmio_write(ODSY_USER_CMD_FIFO, w as u64, 4); } let table = 0xb80000u64; let page = 0xb84000u64; ram.put(page, &abgr); - let header = [0x2000u32, y << 16 | x, 0x50, 0x10001, word4, 0, 4, 0x3201_004c]; + let header = [base, y << 16 | x, pitch, 0x10001, word4, 0, 4, word7]; let mut t = vec![0u8; dma::TABLE_HEADER as usize + 16]; for (i, w) in header.iter().enumerate() { t[i * 4..][..4].copy_from_slice(&w.to_be_bytes()); diff --git a/src/main.rs b/src/main.rs index 1b85d396e..844571f53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,10 @@ fn main() { let _ = iris::workstation::stop_and_flush(&mut *machine, "exit"); #[cfg(feature = "perfetto")] iris::perfetto::shutdown(); + // The window closed: the console's line editor is still waiting for input + // on its own thread, with the terminal in raw mode, and returning from + // main does not drop it. + iris::console_edit::restore_terminal(); } /// `--perfetto FILE [--perfetto-cats LIST] [--perfetto-clock CLOCK]`. diff --git a/src/workstation.rs b/src/workstation.rs index 7f1b2ffe6..1861aee1d 100644 --- a/src/workstation.rs +++ b/src/workstation.rs @@ -753,8 +753,15 @@ fn remove_listed(list: &Mutex>) { /// every exit through here, the terminal console's `quit` too. A guard test /// fails on any other `process::exit` in the library. pub fn exit_process(at: &str, code: i32, stop_and_flush: impl FnOnce() -> Flushed) { + let exiting = std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_none(); + // The console's line editor may be waiting for input on another thread + // with the terminal in raw mode; give the shell its terminal back. Before + // the flush too, so what the flush reports prints as ordinary lines. + if exiting { + crate::console_edit::restore_terminal(); + } let Flushed(()) = stop_and_flush(); - if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { + if !exiting { eprintln!("iris: exit at {at} suppressed (IRIS_NO_EXIT_ON_POWEROFF)"); #[cfg(test)] EXIT_REQUESTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -762,6 +769,7 @@ pub fn exit_process(at: &str, code: i32, stop_and_flush: impl FnOnce() -> Flushe } #[cfg(unix)] remove_listed(&EXIT_FILES); + crate::console_edit::restore_terminal(); std::process::exit(code); }