Skip to main content

badterm_core/
rip.rs

1//! RIPscrip 1.54 graphics: the vector scenes, buttons and clickable mouse
2//! regions of RIPterm-era BBSes, drawn on a 640x350 EGA canvas under the
3//! 80x43 text screen.
4//!
5//! A RIP line starts with `!|` at the beginning of a line and holds commands
6//! separated by `|`, each a command letter (with a level digit before it
7//! for level 1 commands) and its parameters, mostly two-digit base-36
8//! "MegaNums". A backslash at the end of a line continues it on the next;
9//! `\|`, `\\` and `\!` are literal characters in text.
10//!
11//! Drawing follows the Borland BGI the format was built on: colors are
12//! indices into a 16-entry palette of the EGA's 64 colors, lines have a
13//! style and thickness, areas are filled with one of BGI's patterns, and
14//! circles are corrected for the EGA's tall pixels. Text uses the 8x8 font,
15//! scaled up for larger sizes; BGI's vector fonts (Borland's own files) are
16//! drawn in it too.
17//!
18//! Icons (`1I`, `1W`), scenes (`1R`) and file queries (`1F`) use the
19//! BBS's own folder, `RIP` in its file cache, as SyncTERM does; icons and
20//! scenes are also looked for in the downloads folder, where a ZMODEM
21//! download puts them. An icon is a BGI `getimage` image: its width and
22//! height less one (16-bit, little-endian), then each row as four bit
23//! planes, the intensity plane first.
24//!
25//! Left out: variables (`1D`) and RIP's block-mode file transfers.
26
27use std::path::{Path, PathBuf};
28
29use crate::palette::Rgb;
30use crate::screen::{Cell, Screen};
31
32pub const WIDTH: usize = 640;
33pub const HEIGHT: usize = 350;
34/// The text screen over the canvas: 80x43 in the 8x8 font.
35pub const COLS: usize = 80;
36pub const ROWS: usize = 43;
37
38/// What a RIP terminal answers to `ESC [ !`: RIPscrip, version 1.54.
39pub const VERSION_REPLY: &[u8] = b"RIPSCRIP015400";
40
41/// How deep scenes may play other scenes.
42const MAX_SCENE_DEPTH: usize = 4;
43
44/// The largest icon file read, or scene played.
45const MAX_FILE: u64 = 4 << 20;
46
47/// What RIP needs from outside: where its files are, and a way to answer.
48pub struct Io<'a> {
49    /// The BBS's file cache; RIP's own files are in `RIP` in it.
50    pub cache: Option<&'a Path>,
51    /// Where downloads go, also searched for icons and scenes.
52    pub downloads: Option<&'a Path>,
53    pub replies: &'a mut Vec<u8>,
54}
55
56impl Io<'_> {
57    fn rip_dir(&self) -> Option<PathBuf> {
58        Some(self.cache?.join("RIP"))
59    }
60
61    /// An existing file, by name in any case, in the RIP folder or the
62    /// downloads.
63    fn find(&self, name: &str) -> Option<PathBuf> {
64        let dirs = [self.rip_dir(), self.downloads.map(Path::to_path_buf)];
65        dirs.into_iter().flatten().find_map(|dir| {
66            let exact = dir.join(name);
67            if exact.is_file() {
68                return Some(exact);
69            }
70            std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path()).find(|p| {
71                p.is_file() && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.eq_ignore_ascii_case(name))
72            })
73        })
74    }
75}
76
77/// A file name the BBS gave, if it is only a name: no folders, no `..`.
78fn file_name(text: &[u8]) -> Option<String> {
79    let name = std::str::from_utf8(text).ok()?.trim();
80    let bad = |c: char| matches!(c, '/' | '\\' | ':' | '\0') || c.is_control();
81    (!name.is_empty() && !name.contains("..") && !name.chars().any(bad)).then(|| name.to_string())
82}
83
84/// An icon file: BGI's `getimage` layout, 16 colors in four planes.
85fn decode_icon(data: &[u8]) -> Option<(usize, usize, Vec<u8>)> {
86    let w = u16::from_le_bytes([*data.first()?, *data.get(1)?]) as usize + 1;
87    let h = u16::from_le_bytes([*data.get(2)?, *data.get(3)?]) as usize + 1;
88    let row = w.div_ceil(8);
89    let body = data.get(4..4 + row * 4 * h)?;
90    let mut px = Vec::with_capacity(w * h);
91    for planes in body.chunks_exact(row * 4) {
92        for x in 0..w {
93            let bit = |p: usize| (planes[p * row + x / 8] >> (7 - (x & 7))) & 1;
94            px.push(bit(0) << 3 | bit(1) << 2 | bit(2) << 1 | bit(3));
95        }
96    }
97    Some((w, h, px))
98}
99
100fn encode_icon(w: usize, h: usize, px: &[u8]) -> Vec<u8> {
101    let row = w.div_ceil(8);
102    let mut out = Vec::with_capacity(4 + row * 4 * h);
103    out.extend_from_slice(&(w.saturating_sub(1) as u16).to_le_bytes());
104    out.extend_from_slice(&(h.saturating_sub(1) as u16).to_le_bytes());
105    for line in px.chunks_exact(w.max(1)).take(h) {
106        let mut planes = vec![0u8; row * 4];
107        for (x, &c) in line.iter().enumerate() {
108            for p in 0..4 {
109                if c >> (3 - p) & 1 != 0 {
110                    planes[p * row + x / 8] |= 0x80 >> (x & 7);
111                }
112            }
113        }
114        out.extend_from_slice(&planes);
115    }
116    out
117}
118
119/// A file's time as RIP gives it, `mm/dd/yy.hh:mm:ss` (UTC).
120fn rip_time(t: std::time::SystemTime) -> String {
121    let secs = t.duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
122    let (days, rem) = ((secs / 86400) as i64, secs % 86400);
123    // Howard Hinnant's days-to-civil.
124    let z = days + 719_468;
125    let era = z.div_euclid(146_097);
126    let doe = z - era * 146_097;
127    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
128    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
129    let mp = (5 * doy + 2) / 153;
130    let day = doy - (153 * mp + 2) / 5 + 1;
131    let month = if mp < 10 { mp + 3 } else { mp - 9 };
132    let year = yoe + era * 400 + i64::from(month <= 2);
133    format!("{month:02}/{day:02}/{:02}.{:02}:{:02}:{:02}", year % 100, rem / 3600, rem / 60 % 60, rem % 60)
134}
135
136/// The longest RIP line kept, continuations included.
137const MAX_LINE: usize = 64 * 1024;
138
139static FONT_8X8: &[u8; 2048] = include_bytes!("../assets/vga_8x8.bin");
140
141/// The EGA's default palette: indices into its 64 colors.
142const DEFAULT_PALETTE: [u8; 16] = [0, 1, 2, 3, 4, 5, 20, 7, 56, 57, 58, 59, 60, 61, 62, 63];
143
144/// BGI's fill patterns, 8x8, most significant bit leftmost.
145const FILL_PATTERNS: [[u8; 8]; 12] = [
146    [0x00; 8],
147    [0xFF; 8],
148    [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00],
149    [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80],
150    [0xE0, 0xC1, 0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70],
151    [0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x87, 0xC3, 0xE1],
152    [0xA5, 0xD2, 0x69, 0xB4, 0x5A, 0x2D, 0x96, 0x4B],
153    [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88],
154    [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81],
155    [0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33],
156    [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00],
157    [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00],
158];
159
160/// BGI's line styles: solid, dotted, center and dashed.
161const LINE_STYLES: [u16; 4] = [0xFFFF, 0xCCCC, 0xFC78, 0xF8F8];
162
163/// The EGA draws circles this much flatter, so they look round on a 4:3
164/// screen of 640x350 (BGI's aspect ratio for it).
165const ASPECT: f64 = 0.775;
166
167/// One of the EGA's 64 colors (bits: secondary R G B, primary R G B).
168pub fn ega_rgb(c: u8) -> Rgb {
169    let level = |hi: u8, lo: u8| (((c >> hi) & 1) * 0xAA + ((c >> lo) & 1) * 0x55) as u32;
170    (level(2, 5) << 16) | (level(1, 4) << 8) | level(0, 3)
171}
172
173/// A clickable area: when clicked, `text` is sent to the BBS.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct MouseRegion {
176    pub x0: usize,
177    pub y0: usize,
178    pub x1: usize,
179    pub y1: usize,
180    /// Show the area inverted while it is clicked.
181    pub invert: bool,
182    pub text: Vec<u8>,
183}
184
185#[derive(Clone, Copy, Debug, Default)]
186struct ButtonStyle {
187    width: usize,
188    height: usize,
189    bevel: usize,
190    dfore: u8,
191    dback: u8,
192    bright: u8,
193    dark: u8,
194    surface: u8,
195}
196
197#[derive(Clone, Copy, Debug)]
198struct Viewport {
199    x0: usize,
200    y0: usize,
201    x1: usize,
202    y1: usize,
203}
204
205const FULL: Viewport = Viewport { x0: 0, y0: 0, x1: WIDTH - 1, y1: HEIGHT - 1 };
206
207/// A text block being laid out (`1T` ... `1E`).
208struct TextBlock {
209    x0: i32,
210    x1: i32,
211    y: i32,
212}
213
214pub struct Rip {
215    pixels: Vec<u8>,
216    /// Palette entry -> EGA color.
217    palette: [u8; 16],
218    color: u8,
219    fill_color: u8,
220    fill: [u8; 8],
221    line_style: u16,
222    thick: bool,
223    xor: bool,
224    view: Viewport,
225    /// The drawing position (for text), relative to the viewport.
226    pos: (i32, i32),
227    font_size: usize,
228    vertical: bool,
229    button: ButtonStyle,
230    regions: Vec<MouseRegion>,
231    clipboard: Option<(usize, usize, Vec<u8>)>,
232    text_block: Option<TextBlock>,
233    /// The text window, in text cells (x0, y0, x1, y1).
234    text_window: (usize, usize, usize, usize),
235    /// The RIP line being read, from its `!`.
236    line: Option<Vec<u8>>,
237    /// A RIP line just ended with CR: its LF goes too.
238    pub(crate) eat_lf: bool,
239    /// A scene file (`1R`) to play before what follows.
240    scene: Option<Vec<u8>>,
241    /// How many scenes are playing inside each other.
242    pub scene_depth: usize,
243    /// Changed since the front end last looked.
244    pub dirty: bool,
245}
246
247/// What [`Rip::byte`] did with a byte.
248pub enum Feed {
249    /// Not RIP: handle it as text.
250    Pass,
251    /// Taken.
252    Done,
253    /// It turned out not to be a RIP line: show these bytes as text.
254    Text(Vec<u8>),
255}
256
257impl Default for Rip {
258    fn default() -> Self {
259        Rip {
260            pixels: vec![0; WIDTH * HEIGHT],
261            palette: DEFAULT_PALETTE,
262            color: 15,
263            fill_color: 15,
264            fill: FILL_PATTERNS[1],
265            line_style: 0xFFFF,
266            thick: false,
267            xor: false,
268            view: FULL,
269            pos: (0, 0),
270            font_size: 1,
271            vertical: false,
272            button: ButtonStyle::default(),
273            regions: Vec::new(),
274            clipboard: None,
275            text_block: None,
276            text_window: (0, 0, COLS - 1, ROWS - 1),
277            line: None,
278            eat_lf: false,
279            scene: None,
280            scene_depth: 0,
281            dirty: true,
282        }
283    }
284}
285
286impl Rip {
287    /// The canvas: `WIDTH` x `HEIGHT` palette indices.
288    pub fn pixels(&self) -> &[u8] {
289        &self.pixels
290    }
291
292    /// The colors of the 16 palette entries.
293    pub fn palette(&self) -> [Rgb; 16] {
294        self.palette.map(ega_rgb)
295    }
296
297    pub fn regions(&self) -> &[MouseRegion] {
298        &self.regions
299    }
300
301    /// What clicking the canvas at (x, y) sends, if it is in a mouse region
302    /// (the last defined wins, as they are drawn over each other).
303    pub fn click(&self, x: usize, y: usize) -> Option<Vec<u8>> {
304        self.regions
305            .iter()
306            .rev()
307            .find(|r| (r.x0..=r.x1).contains(&x) && (r.y0..=r.y1).contains(&y))
308            .map(|r| r.text.clone())
309    }
310
311    /// Take a byte at the text level: `at_line_start` says whether it
312    /// begins a line, where a RIP line may start.
313    pub fn byte(&mut self, s: &mut Screen, b: u8, at_line_start: bool, io: &mut Io) -> Feed {
314        if std::mem::take(&mut self.eat_lf) && b == b'\n' {
315            return Feed::Done;
316        }
317        let Some(line) = self.line.as_mut() else {
318            if at_line_start && b == b'!' {
319                self.line = Some(vec![b]);
320                return Feed::Done;
321            }
322            return Feed::Pass;
323        };
324        // `!` must be followed by `|`; otherwise it was text.
325        if line.len() == 1 && b != b'|' {
326            let mut text = self.line.take().unwrap_or_default();
327            text.push(b);
328            return Feed::Text(text);
329        }
330        match b {
331            b'\r' | b'\n' => {
332                // A backslash before the end of the line continues it.
333                if line.last() == Some(&b'\\') && !line.ends_with(b"\\\\") {
334                    line.pop();
335                    line.push(0);
336                    return Feed::Done;
337                }
338                if line.last() == Some(&0) {
339                    // The LF of a continued line's CR LF.
340                    return Feed::Done;
341                }
342                let line = self.line.take().unwrap_or_default();
343                self.run_line(s, &line, io);
344                // The line's end is not shown: RIP lines are invisible.
345                self.eat_lf = b == b'\r';
346                Feed::Done
347            }
348            _ => {
349                if line.last() == Some(&0) {
350                    line.pop();
351                }
352                if line.len() < MAX_LINE {
353                    line.push(b);
354                }
355                Feed::Done
356            }
357        }
358    }
359
360    /// A scene file a `1R` asked for: the caller plays it as if it came
361    /// from the BBS, before the rest of what did.
362    pub fn take_scene(&mut self) -> Option<Vec<u8>> {
363        self.scene.take()
364    }
365
366    fn run_line(&mut self, s: &mut Screen, line: &[u8], io: &mut Io) {
367        // Split on unescaped bars; the first piece is the `!`.
368        let mut cmds: Vec<Vec<u8>> = vec![Vec::new()];
369        let mut i = 1;
370        while i < line.len() {
371            match line[i] {
372                b'\\' if i + 1 < line.len() => {
373                    cmds.last_mut().expect("never empty").push(line[i + 1]);
374                    i += 1;
375                }
376                b'|' => cmds.push(Vec::new()),
377                c => cmds.last_mut().expect("never empty").push(c),
378            }
379            i += 1;
380        }
381        for cmd in cmds.iter().filter(|c| !c.is_empty()) {
382            self.command(s, cmd, io);
383        }
384        self.dirty = true;
385    }
386
387    fn command(&mut self, s: &mut Screen, cmd: &[u8], io: &mut Io) {
388        let (level, op, args) = match cmd {
389            [d @ b'1'..=b'9', op, args @ ..] => (d - b'0', *op, args),
390            [op, args @ ..] => (0, *op, args),
391            [] => return,
392        };
393        let mut a = Args(args);
394        match (level, op) {
395            (0, b'w') => {
396                let (x0, y0, x1, y1) = (a.n2(), a.n2(), a.n2(), a.n2());
397                let (x1, y1) = (x1.clamp(x0, COLS - 1), y1.clamp(y0, ROWS - 1));
398                self.text_window = (x0.min(COLS - 1), y0.min(ROWS - 1), x1, y1);
399                s.set_scroll_region(self.text_window.1, self.text_window.3);
400                s.move_to(self.text_window.0, self.text_window.1);
401            }
402            (0, b'v') => {
403                let (x0, y0, x1, y1) = (a.n2(), a.n2(), a.n2(), a.n2());
404                self.view = Viewport {
405                    x0: x0.min(WIDTH - 1),
406                    y0: y0.min(HEIGHT - 1),
407                    x1: x1.clamp(x0.min(WIDTH - 1), WIDTH - 1),
408                    y1: y1.clamp(y0.min(HEIGHT - 1), HEIGHT - 1),
409                };
410            }
411            (0, b'*') => self.reset(s),
412            (0, b'e') => {
413                let (x0, y0, x1, y1) = self.text_window;
414                s.fill_rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, Cell { ch: b' ', ..s.pen });
415                s.move_to(x0, y0);
416            }
417            (0, b'E') => {
418                let v = self.view;
419                for y in v.y0..=v.y1 {
420                    self.pixels[y * WIDTH + v.x0..=y * WIDTH + v.x1].fill(0);
421                }
422            }
423            (0, b'g') => {
424                let (x, y) = (a.n2(), a.n2());
425                s.move_to(self.text_window.0 + x, self.text_window.1 + y);
426            }
427            (0, b'H') => s.move_to(self.text_window.0, self.text_window.1),
428            (0, b'>') => s.erase_line(0),
429            (0, b'c') => self.color = a.n2() as u8 & 15,
430            (0, b'Q') => {
431                for i in 0..16 {
432                    self.palette[i] = a.n2() as u8 & 63;
433                }
434            }
435            (0, b'a') => {
436                let (i, c) = (a.n2(), a.n2());
437                self.palette[i & 15] = c as u8 & 63;
438            }
439            (0, b'W') => self.xor = a.n2() == 1,
440            (0, b'm') => self.pos = (a.i2(), a.i2()),
441            (0, b'T') => {
442                let text = a.rest().to_vec();
443                self.text(&text);
444            }
445            (0, b'@') => {
446                self.pos = (a.i2(), a.i2());
447                let text = a.rest().to_vec();
448                self.text(&text);
449            }
450            (0, b'Y') => {
451                let (_font, dir, size) = (a.n2(), a.n2(), a.n2());
452                self.vertical = dir == 1;
453                self.font_size = size.clamp(1, 10);
454            }
455            (0, b'X') => {
456                let (x, y) = (a.i2(), a.i2());
457                self.plot(x, y, self.color);
458            }
459            (0, b'L') => {
460                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
461                self.line(x0, y0, x1, y1);
462            }
463            (0, b'R') => {
464                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
465                self.polyline(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)], true);
466            }
467            (0, b'B') => {
468                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
469                self.bar(x0, y0, x1, y1);
470            }
471            (0, b'C') => {
472                let (x, y, r) = (a.i2(), a.i2(), a.i2());
473                let pts = arc_points(x, y, 0, 360, r as f64, r as f64 * ASPECT);
474                self.polyline(&pts, false);
475            }
476            (0, b'O') => {
477                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
478                let pts = arc_points(x, y, st, end, xr as f64, yr as f64);
479                self.polyline(&pts, false);
480            }
481            (0, b'o') => {
482                let (x, y, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2());
483                let pts = arc_points(x, y, 0, 360, xr as f64, yr as f64);
484                self.fill_polygon(&pts);
485                self.polyline(&pts, false);
486            }
487            (0, b'A') => {
488                let (x, y, st, end, r) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
489                let pts = arc_points(x, y, st, end, r as f64, r as f64 * ASPECT);
490                self.polyline(&pts, false);
491            }
492            (0, b'V') => {
493                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
494                let pts = arc_points(x, y, st, end, xr as f64, yr as f64);
495                self.polyline(&pts, false);
496            }
497            (0, b'I') => {
498                let (x, y, st, end, r) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
499                self.pie(x, y, st, end, r as f64, r as f64 * ASPECT);
500            }
501            (0, b'i') => {
502                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
503                self.pie(x, y, st, end, xr as f64, yr as f64);
504            }
505            (0, b'Z') => {
506                let p: Vec<(f64, f64)> = (0..4).map(|_| (a.i2() as f64, a.i2() as f64)).collect();
507                let n = a.n2().max(1);
508                let pts: Vec<(i32, i32)> = (0..=n)
509                    .map(|i| {
510                        let t = i as f64 / n as f64;
511                        let u = 1.0 - t;
512                        let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
513                        let x = b0 * p[0].0 + b1 * p[1].0 + b2 * p[2].0 + b3 * p[3].0;
514                        let y = b0 * p[0].1 + b1 * p[1].1 + b2 * p[2].1 + b3 * p[3].1;
515                        (x.round() as i32, y.round() as i32)
516                    })
517                    .collect();
518                self.polyline(&pts, false);
519            }
520            (0, b'P' | b'p' | b'l') => {
521                let n = a.n2();
522                let pts: Vec<(i32, i32)> = (0..n).map(|_| (a.i2(), a.i2())).collect();
523                if op == b'p' {
524                    self.fill_polygon(&pts);
525                }
526                self.polyline(&pts, op != b'l');
527            }
528            (0, b'F') => {
529                let (x, y, border) = (a.i2(), a.i2(), a.n2() as u8);
530                self.flood(x, y, border & 15);
531            }
532            (0, b'=') => {
533                let (style, user, thick) = (a.n2(), a.n4(), a.n2());
534                self.line_style = LINE_STYLES.get(style).copied().unwrap_or(user as u16);
535                self.thick = thick >= 3;
536            }
537            (0, b'S') => {
538                let (pattern, color) = (a.n2(), a.n2());
539                self.fill = FILL_PATTERNS.get(pattern).copied().unwrap_or(FILL_PATTERNS[1]);
540                self.fill_color = color as u8 & 15;
541            }
542            (0, b's') => {
543                for row in self.fill.iter_mut() {
544                    *row = a.n2() as u8;
545                }
546                self.fill_color = a.n2() as u8 & 15;
547            }
548            (0, b'#') => {}
549            (1, b'M') => {
550                let (_num, x0, y0, x1, y1, clk, _clr) = (a.n2(), a.n2(), a.n2(), a.n2(), a.n2(), a.n1(), a.n1());
551                a.skip(5);
552                let text = host_command(a.rest());
553                self.region(x0, y0, x1, y1, clk == 1, text);
554            }
555            (1, b'K') => self.regions.clear(),
556            (1, b'T') => {
557                let (x0, y0, x1) = (a.i2(), a.i2(), a.i2());
558                self.text_block = Some(TextBlock { x0, x1, y: y0 });
559            }
560            (1, b't') => {
561                let _justify = a.n1();
562                let text = a.rest().to_vec();
563                if let Some(tb) = self.text_block.as_mut() {
564                    let (x0, x1, y) = (tb.x0, tb.x1, tb.y);
565                    tb.y += 8 * self.font_size as i32;
566                    let room = ((x1 - x0).max(0) as usize / (8 * self.font_size)).max(1);
567                    self.pos = (x0, y);
568                    self.text(&text[..text.len().min(room)]);
569                }
570            }
571            (1, b'E') => self.text_block = None,
572            (1, b'C') => {
573                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
574                self.copy_to_clipboard(x0, y0, x1, y1);
575            }
576            (1, b'P') => {
577                let (x, y, mode) = (a.i2(), a.i2(), a.n2());
578                self.paste(x, y, mode);
579            }
580            (1, b'G') => {
581                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
582                a.skip(2);
583                let dest = a.i2();
584                self.copy_rows(x0, y0, x1, y1, dest);
585            }
586            (1, b'B') => {
587                self.button = ButtonStyle {
588                    width: a.n2(),
589                    height: a.n2(),
590                    bevel: {
591                        // The orientation and flags (icons, hot keys,
592                        // radio groups) are not used.
593                        a.skip(6);
594                        a.n2()
595                    },
596                    dfore: a.n2() as u8 & 15,
597                    dback: a.n2() as u8 & 15,
598                    bright: a.n2() as u8 & 15,
599                    dark: a.n2() as u8 & 15,
600                    surface: a.n2() as u8 & 15,
601                };
602            }
603            (1, b'U') => {
604                let (x0, y0, x1, y1, _hotkey, _flags) = (a.i2(), a.i2(), a.i2(), a.i2(), a.n2(), a.n1());
605                a.skip(1);
606                let text = a.rest().to_vec();
607                self.draw_button(x0, y0, x1, y1, &text);
608            }
609            (1, b'I') => {
610                let (x, y, mode, clip) = (a.i2(), a.i2(), a.n2(), a.n1() == 1);
611                a.skip(2);
612                if let Some(name) = file_name(a.rest()) {
613                    let name = if name.contains('.') { name } else { format!("{name}.ICN") };
614                    self.load_icon(x, y, mode, clip, &name, io);
615                }
616            }
617            (1, b'W') => {
618                a.skip(1);
619                if let (Some(name), Some((w, h, px))) = (file_name(a.rest()), &self.clipboard) {
620                    let data = encode_icon(*w, *h, px);
621                    let saved = io.rip_dir().ok_or_else(|| anyhow::anyhow!("no file cache")).and_then(|dir| {
622                        std::fs::create_dir_all(&dir)?;
623                        Ok(std::fs::write(dir.join(&name), data)?)
624                    });
625                    if let Err(e) = saved {
626                        log::warn!("RIP icon {name}: {e:#}");
627                    }
628                }
629            }
630            (1, b'R') => {
631                a.skip(8);
632                let found = file_name(a.rest()).and_then(|n| io.find(&n));
633                match found.map(std::fs::read) {
634                    _ if self.scene_depth >= MAX_SCENE_DEPTH => log::warn!("RIP scenes nested too deep"),
635                    Some(Ok(data)) if data.len() as u64 <= MAX_FILE => self.scene = Some(data),
636                    _ => log::info!("RIP scene {:?} not found", String::from_utf8_lossy(a.rest())),
637                }
638            }
639            (1, b'F') => {
640                let mode = a.n2();
641                a.skip(4);
642                let Some(name) = file_name(a.rest()) else { return };
643                let meta = io.find(&name).and_then(|p| std::fs::metadata(p).ok());
644                let reply = match (mode, meta) {
645                    (0, m) => if m.is_some() { "1" } else { "0" }.to_string(),
646                    (1, m) => if m.is_some() { "1\r\n" } else { "0\r\n" }.to_string(),
647                    (_, None) => "0\r\n".to_string(),
648                    (2, Some(m)) => format!("1.{}\r\n", m.len()),
649                    (3, Some(m)) => {
650                        format!("1.{}.{}\r\n", m.len(), rip_time(m.modified().unwrap_or(std::time::UNIX_EPOCH)))
651                    }
652                    (_, Some(m)) => {
653                        let t = rip_time(m.modified().unwrap_or(std::time::UNIX_EPOCH));
654                        format!("1.{}.{}.{t}\r\n", name.to_ascii_uppercase(), m.len())
655                    }
656                };
657                io.replies.extend_from_slice(reply.as_bytes());
658            }
659            _ => log::debug!("unhandled RIP command {level}{}", op as char),
660        }
661    }
662
663    /// `RIP_RESET_WINDOWS`: everything back as it starts.
664    fn reset(&mut self, s: &mut Screen) {
665        *self = Rip { line: self.line.take(), eat_lf: self.eat_lf, scene_depth: self.scene_depth, ..Rip::default() };
666        s.set_scroll_region(0, s.rows() - 1);
667        s.erase_display(2);
668        s.move_to(0, 0);
669    }
670
671    /// Set a pixel, at viewport coordinates, clipped to the viewport.
672    fn plot(&mut self, x: i32, y: i32, c: u8) {
673        let v = self.view;
674        let (x, y) = (x + v.x0 as i32, y + v.y0 as i32);
675        if x < v.x0 as i32 || y < v.y0 as i32 || x > v.x1 as i32 || y > v.y1 as i32 {
676            return;
677        }
678        let p = &mut self.pixels[y as usize * WIDTH + x as usize];
679        *p = if self.xor { *p ^ c } else { c };
680    }
681
682    fn get(&self, x: i32, y: i32) -> Option<u8> {
683        let v = self.view;
684        let (x, y) = (x + v.x0 as i32, y + v.y0 as i32);
685        (x >= v.x0 as i32 && y >= v.y0 as i32 && x <= v.x1 as i32 && y <= v.y1 as i32)
686            .then(|| self.pixels[y as usize * WIDTH + x as usize])
687    }
688
689    /// A pixel of the fill pattern: the fill color where its bit is set,
690    /// the background (0) elsewhere.
691    fn fill_at(&mut self, x: i32, y: i32) {
692        let v = self.view;
693        let bit = self.fill[(y + v.y0 as i32).rem_euclid(8) as usize] & (0x80 >> (x + v.x0 as i32).rem_euclid(8));
694        let c = if bit != 0 { self.fill_color } else { 0 };
695        self.plot(x, y, c);
696    }
697
698    /// A line in the line style, three pixels wide when thick.
699    fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
700        let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
701        let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
702        let steep = -dy > dx;
703        let (mut x, mut y, mut err) = (x0, y0, dx + dy);
704        let mut n = 0u32;
705        loop {
706            if self.line_style & (0x8000 >> (n % 16)) != 0 {
707                self.plot(x, y, self.color);
708                if self.thick {
709                    let (ox, oy) = if steep { (1, 0) } else { (0, 1) };
710                    self.plot(x - ox, y - oy, self.color);
711                    self.plot(x + ox, y + oy, self.color);
712                }
713            }
714            n += 1;
715            if x == x1 && y == y1 {
716                break;
717            }
718            let e2 = 2 * err;
719            if e2 >= dy {
720                err += dy;
721                x += sx;
722            }
723            if e2 <= dx {
724                err += dx;
725                y += sy;
726            }
727        }
728    }
729
730    fn polyline(&mut self, pts: &[(i32, i32)], closed: bool) {
731        for w in pts.windows(2) {
732            self.line(w[0].0, w[0].1, w[1].0, w[1].1);
733        }
734        if closed && pts.len() > 2 {
735            let (a, b) = (pts[pts.len() - 1], pts[0]);
736            self.line(a.0, a.1, b.0, b.1);
737        }
738    }
739
740    fn bar(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
741        for y in y0.min(y1)..=y0.max(y1) {
742            for x in x0.min(x1)..=x0.max(x1) {
743                self.fill_at(x, y);
744            }
745        }
746    }
747
748    /// Fill a polygon (even-odd, by scan lines) with the fill pattern.
749    fn fill_polygon(&mut self, pts: &[(i32, i32)]) {
750        if pts.len() < 3 {
751            return;
752        }
753        let (top, bottom) = pts.iter().fold((i32::MAX, i32::MIN), |(t, b), &(_, y)| (t.min(y), b.max(y)));
754        for y in top..=bottom {
755            let yc = y as f64 + 0.5;
756            let mut xs: Vec<f64> = Vec::new();
757            for i in 0..pts.len() {
758                let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
759                let (ay, by) = (a.1 as f64, b.1 as f64);
760                if (ay <= yc) != (by <= yc) {
761                    xs.push(a.0 as f64 + (yc - ay) / (by - ay) * (b.0 - a.0) as f64);
762                }
763            }
764            xs.sort_by(f64::total_cmp);
765            for pair in xs.chunks(2) {
766                if let [l, r] = pair {
767                    for x in l.round() as i32..=r.round() as i32 {
768                        self.fill_at(x, y);
769                    }
770                }
771            }
772        }
773    }
774
775    fn pie(&mut self, x: i32, y: i32, st: i32, end: i32, xr: f64, yr: f64) {
776        let mut pts = vec![(x, y)];
777        pts.extend(arc_points(x, y, st, end, xr, yr));
778        self.fill_polygon(&pts);
779        self.polyline(&pts, true);
780    }
781
782    /// Flood the area around (x, y) up to the border color with the fill
783    /// pattern.
784    fn flood(&mut self, x: i32, y: i32, border: u8) {
785        let v = self.view;
786        let (w, h) = ((v.x1 - v.x0 + 1) as i32, (v.y1 - v.y0 + 1) as i32);
787        if self.get(x, y).is_none_or(|c| c == border) {
788            return;
789        }
790        let mut seen = vec![false; (w * h) as usize];
791        let mut stack = vec![(x, y)];
792        while let Some((px, py)) = stack.pop() {
793            if px < 0 || py < 0 || px >= w || py >= h {
794                continue;
795            }
796            let i = (py * w + px) as usize;
797            if seen[i] || self.get(px, py) == Some(border) {
798                continue;
799            }
800            // Fill this run of the line, then look above and below it.
801            let mut l = px;
802            while l > 0 && !seen[(py * w + l - 1) as usize] && self.get(l - 1, py) != Some(border) {
803                l -= 1;
804            }
805            let mut r = px;
806            while r + 1 < w && !seen[(py * w + r + 1) as usize] && self.get(r + 1, py) != Some(border) {
807                r += 1;
808            }
809            for xx in l..=r {
810                seen[(py * w + xx) as usize] = true;
811                self.fill_at(xx, py);
812                stack.push((xx, py - 1));
813                stack.push((xx, py + 1));
814            }
815        }
816    }
817
818    /// Draw text at the drawing position in the 8x8 font, scaled by the
819    /// font size, leaving the background; moves the position past it.
820    fn text(&mut self, text: &[u8]) {
821        let k = self.font_size as i32;
822        for &ch in text {
823            let (x0, y0) = self.pos;
824            for gy in 0..8 {
825                let bits = FONT_8X8[ch as usize * 8 + gy as usize];
826                for gx in 0..8 {
827                    if bits & (0x80 >> gx) == 0 {
828                        continue;
829                    }
830                    for sy in 0..k {
831                        for sx in 0..k {
832                            // Vertical text runs upwards, rotated.
833                            let (px, py) = if self.vertical {
834                                (x0 + gy * k + sy, y0 - gx * k - sx)
835                            } else {
836                                (x0 + gx * k + sx, y0 + gy * k + sy)
837                            };
838                            self.plot(px, py, self.color);
839                        }
840                    }
841                }
842            }
843            if self.vertical {
844                self.pos.1 -= 8 * k;
845            } else {
846                self.pos.0 += 8 * k;
847            }
848        }
849    }
850
851    fn region(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, invert: bool, text: Vec<u8>) {
852        let (x1, y1) = (x1.max(x0).min(WIDTH - 1), y1.max(y0).min(HEIGHT - 1));
853        self.regions.push(MouseRegion { x0, y0, x1, y1, invert, text });
854    }
855
856    fn copy_to_clipboard(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
857        let (w, h) = ((x1 - x0 + 1).max(0) as usize, (y1 - y0 + 1).max(0) as usize);
858        let data = (0..h as i32)
859            .flat_map(|dy| (0..w as i32).map(move |dx| (dx, dy)))
860            .map(|(dx, dy)| self.get(x0 + dx, y0 + dy).unwrap_or(0))
861            .collect();
862        self.clipboard = Some((w, h, data));
863    }
864
865    /// Put the clipboard at (x, y): copied, XORed, ORed, ANDed or inverted.
866    fn paste(&mut self, x: i32, y: i32, mode: usize) {
867        let Some((w, h, data)) = self.clipboard.clone() else { return };
868        self.stamp(x, y, mode, w, h, &data);
869    }
870
871    /// `1I`: an icon file stamped at (x, y) as [`Rip::paste`] does, and
872    /// put on the clipboard too if asked. An icon that would not fit in
873    /// the viewport is not drawn; a missing one is a box saying so.
874    fn load_icon(&mut self, x: i32, y: i32, mode: usize, clip: bool, name: &str, io: &Io) {
875        let file = io.find(name).filter(|p| std::fs::metadata(p).is_ok_and(|m| m.len() <= MAX_FILE));
876        match file.and_then(|p| std::fs::read(p).ok()) {
877            Some(data) => {
878                let Some((w, h, px)) = decode_icon(&data) else {
879                    log::warn!("RIP icon {name}: not an icon file");
880                    return;
881                };
882                let v = self.view;
883                if v.x0 + x as usize + w - 1 > v.x1 || v.y0 + y as usize + h - 1 > v.y1 {
884                    return;
885                }
886                self.stamp(x, y, mode, w, h, &px);
887                if clip {
888                    self.clipboard = Some((w, h, px));
889                }
890            }
891            None => {
892                let label = format!("Icon {}", name.to_ascii_uppercase());
893                let saved = (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor);
894                (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor) =
895                    (12, 0xCCCC, false, 1, false, false);
896                let width = 8 * label.len().max(13) as i32;
897                let (x1, y1) = (x + width + 24, y + 37);
898                self.polyline(&[(x, y), (x1, y), (x1, y1), (x, y1)], true);
899                for (text, dy) in [(label.as_bytes(), 6), (b"not available".as_slice(), 20)] {
900                    self.pos = (x + (x1 - x + 1 - 8 * text.len() as i32) / 2, y + dy);
901                    self.text(text);
902                }
903                (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor) = saved;
904                if clip {
905                    self.copy_to_clipboard(x, y, x1, y1);
906                }
907            }
908        }
909    }
910
911    fn stamp(&mut self, x: i32, y: i32, mode: usize, w: usize, h: usize, data: &[u8]) {
912        let xor = self.xor;
913        self.xor = false;
914        for dy in 0..h {
915            for dx in 0..w {
916                let (px, py) = (x + dx as i32, y + dy as i32);
917                let (src, dst) = (data[dy * w + dx], self.get(px, py).unwrap_or(0));
918                let c = match mode {
919                    1 => dst ^ src,
920                    2 => dst | src,
921                    3 => dst & src,
922                    4 => !src & 15,
923                    _ => src,
924                };
925                self.plot(px, py, c);
926            }
927        }
928        self.xor = xor;
929    }
930
931    /// `1G`: copy lines y0..=y1 (columns x0..=x1) to start at line `dest`.
932    fn copy_rows(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, dest: i32) {
933        let clipboard = self.clipboard.take();
934        self.copy_to_clipboard(x0, y0, x1, y1);
935        self.paste(x0, dest, 0);
936        self.clipboard = clipboard;
937    }
938
939    /// `1U`: a button in the current style, with a mouse region when it has
940    /// a host command. Its text is `icon<>label<>command`.
941    fn draw_button(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, text: &[u8]) {
942        let st = self.button;
943        let (x1, y1) = if st.width > 0 && st.height > 0 {
944            (x0 + st.width as i32 - 1, y0 + st.height as i32 - 1)
945        } else {
946            (x1, y1)
947        };
948        let mut parts = split_on(text, b"<>");
949        let (_icon, label, command) =
950            (parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
951        let saved = (self.fill, self.fill_color, self.color, self.line_style, self.thick, self.xor);
952        (self.fill, self.line_style, self.thick, self.xor) = (FILL_PATTERNS[1], 0xFFFF, false, false);
953        let b = st.bevel as i32;
954        // The bevel: light along the top and left, dark along the others.
955        if b > 0 {
956            self.fill_color = st.bright;
957            self.fill_polygon(&[(x0 - b, y0 - b), (x1 + b, y0 - b), (x1, y0), (x0, y0), (x0, y1), (x0 - b, y1 + b)]);
958            self.fill_color = st.dark;
959            self.fill_polygon(&[(x1 + b, y0 - b), (x1 + b, y1 + b), (x0 - b, y1 + b), (x0, y1), (x1, y1), (x1, y0)]);
960        }
961        self.fill_color = st.surface;
962        self.bar(x0, y0, x1, y1);
963        // The label, centered, with its shadow one pixel down and right.
964        let k = self.font_size as i32;
965        let (lw, lh) = (label.len() as i32 * 8 * k, 8 * k);
966        let (lx, ly) = (x0 + (x1 - x0 + 1 - lw) / 2, y0 + (y1 - y0 + 1 - lh) / 2);
967        let vertical = std::mem::replace(&mut self.vertical, false);
968        if st.dback != st.surface {
969            self.color = st.dback;
970            self.pos = (lx + 1, ly + 1);
971            self.text(label);
972        }
973        self.color = st.dfore;
974        self.pos = (lx, ly);
975        self.text(label);
976        self.vertical = vertical;
977        (self.fill, self.fill_color, self.color, self.line_style, self.thick, self.xor) = saved;
978        if !command.is_empty() {
979            let v = self.view;
980            let at = |p: i32, o: usize| (p + o as i32).max(0) as usize;
981            self.region(at(x0, v.x0), at(y0, v.y0), at(x1, v.x0), at(y1, v.y0), true, host_command(command));
982        }
983    }
984}
985
986/// Points along an elliptical arc from `st` to `end` degrees, counter-
987/// clockwise from three o'clock as in BGI.
988fn arc_points(cx: i32, cy: i32, st: i32, end: i32, xr: f64, yr: f64) -> Vec<(i32, i32)> {
989    let end = if end <= st { end + 360 } else { end };
990    let steps = ((end - st) as f64 * (xr.max(yr) / 20.0).max(1.0) / 3.0).ceil().clamp(4.0, 720.0) as usize;
991    (0..=steps)
992        .map(|i| {
993            let a = (st as f64 + (end - st) as f64 * i as f64 / steps as f64).to_radians();
994            ((cx as f64 + xr * a.cos()).round() as i32, (cy as f64 - yr * a.sin()).round() as i32)
995        })
996        .collect()
997}
998
999/// A host command's text, with `^M`-style control characters made real.
1000fn host_command(text: &[u8]) -> Vec<u8> {
1001    let mut out = Vec::with_capacity(text.len());
1002    let mut i = 0;
1003    while i < text.len() {
1004        match (text[i], text.get(i + 1)) {
1005            (b'^', Some(&c)) if (b'@'..=b'_').contains(&c.to_ascii_uppercase()) => {
1006                out.push(c.to_ascii_uppercase() - b'@');
1007                i += 2;
1008            }
1009            (c, _) => {
1010                out.push(c);
1011                i += 1;
1012            }
1013        }
1014    }
1015    out
1016}
1017
1018fn split_on<'a>(text: &'a [u8], sep: &'a [u8]) -> impl Iterator<Item = &'a [u8]> {
1019    let mut rest = Some(text);
1020    std::iter::from_fn(move || {
1021        let r = rest?;
1022        match r.windows(sep.len()).position(|w| w == sep) {
1023            Some(i) => {
1024                rest = Some(&r[i + sep.len()..]);
1025                Some(&r[..i])
1026            }
1027            None => {
1028                rest = None;
1029                Some(r)
1030            }
1031        }
1032    })
1033}
1034
1035/// A command's parameters: MegaNums (base 36) and the text after them.
1036struct Args<'a>(&'a [u8]);
1037
1038impl Args<'_> {
1039    fn digits(&mut self, n: usize) -> usize {
1040        let (head, tail) = self.0.split_at(n.min(self.0.len()));
1041        self.0 = tail;
1042        head.iter().fold(0, |v, &c| {
1043            let d = match c {
1044                b'0'..=b'9' => c - b'0',
1045                b'A'..=b'Z' => c - b'A' + 10,
1046                b'a'..=b'z' => c - b'a' + 10,
1047                _ => 0,
1048            };
1049            v * 36 + d as usize
1050        })
1051    }
1052
1053    fn n1(&mut self) -> usize {
1054        self.digits(1)
1055    }
1056
1057    fn n2(&mut self) -> usize {
1058        self.digits(2)
1059    }
1060
1061    fn n4(&mut self) -> usize {
1062        self.digits(4)
1063    }
1064
1065    fn i2(&mut self) -> i32 {
1066        self.n2() as i32
1067    }
1068
1069    fn skip(&mut self, n: usize) {
1070        self.0 = &self.0[n.min(self.0.len())..];
1071    }
1072
1073    fn rest(&self) -> &[u8] {
1074        self.0
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::ansi::{Encoding, Terminal};
1082
1083    fn term(data: &[u8]) -> Terminal {
1084        let mut t = Terminal::new(COLS, ROWS, 0);
1085        t.encoding = Encoding::Rip;
1086        t.start_emulation();
1087        t.feed(data);
1088        t
1089    }
1090
1091    fn px(t: &Terminal, x: usize, y: usize) -> u8 {
1092        t.rip().expect("RIP is on").pixels()[y * WIDTH + x]
1093    }
1094
1095    #[test]
1096    fn megnums_and_escapes() {
1097        let mut a = Args(b"0A1Z00hello\\|");
1098        assert_eq!((a.n2(), a.n2(), a.n2()), (10, 71, 0));
1099        assert_eq!(host_command(b"go^m"), b"go\r");
1100        assert_eq!(split_on(b"a<>b<>c", b"<>").collect::<Vec<_>>(), [b"a" as &[u8], b"b", b"c"]);
1101    }
1102
1103    #[test]
1104    fn draws_lines_bars_and_text_beside_ansi() {
1105        // Yellow (14) line, a red bar, then text on the text screen.
1106        let t = term(b"!|c0E|L00000A00|S0104|B0A0A0F0F\r\nHello\r\n");
1107        assert_eq!((px(&t, 0, 0), px(&t, 10, 0), px(&t, 11, 0)), (14, 14, 0));
1108        assert_eq!((px(&t, 12, 12), px(&t, 16, 16)), (4, 0));
1109        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "Hello");
1110        // `!` not followed by `|` is just text.
1111        let t = term(b"!Hi\r\n");
1112        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "!Hi");
1113    }
1114
1115    #[test]
1116    fn continuation_fill_and_reset() {
1117        // A rectangle, continued over a line break, then flood its inside.
1118        let t = term(b"!|c0F|R0A0A1414|S01\\\r\n02|F0F0F0F\r\n");
1119        assert_eq!((px(&t, 10, 10), px(&t, 15, 15), px(&t, 39, 39), px(&t, 50, 50)), (15, 2, 2, 0));
1120        let t = term(b"!|c0F|L00000Z00|*\r\n");
1121        assert_eq!(px(&t, 5, 0), 0);
1122    }
1123
1124    #[test]
1125    fn buttons_and_mouse_regions() {
1126        let t = term(b"!|1B000000000002000F0F0807000000000000|1U0A0A1E140000<>OK<>yes^M\r\n");
1127        let rip = t.rip().unwrap();
1128        assert_eq!(rip.click(15, 15), Some(b"yes\r".to_vec()));
1129        assert_eq!(rip.click(100, 100), None);
1130        // The surface is light gray (7), the bevel white above and left.
1131        assert_eq!(px(&t, 11, 19), 7);
1132        assert_eq!(px(&t, 9, 12), 15);
1133        let t = term(b"!|1M00000010101100000say hi\r\n");
1134        assert_eq!(t.rip().unwrap().click(5, 5), Some(b"say hi".to_vec()));
1135        let t = term(b"!|1M00000010101100000say hi\r\n!|1K\r\n");
1136        assert_eq!(t.rip().unwrap().click(5, 5), None);
1137    }
1138
1139    #[test]
1140    fn icons_scenes_and_file_queries() {
1141        let dir = std::env::temp_dir().join(format!("badterm-rip-test-{}", std::process::id()));
1142        let _ = std::fs::remove_dir_all(&dir);
1143        let mut t = Terminal::new(COLS, ROWS, 0);
1144        t.encoding = Encoding::Rip;
1145        t.start_emulation();
1146        t.syncterm.cache_dir = Some(dir.clone());
1147        // A 10x3 bar of color 9 (odd width: planes pad to a byte), copied
1148        // and written out as an icon.
1149        t.feed(b"!|S0109|B00000902|1C00000902|1W0BAR.ICN\r\n");
1150        let icn = std::fs::read(dir.join("RIP/BAR.ICN")).unwrap();
1151        assert_eq!(icn.len(), 4 + 2 * 4 * 3);
1152        assert_eq!(decode_icon(&icn), Some((10, 3, vec![9; 30])));
1153        // Loaded elsewhere (any case, the extension added), XORed.
1154        t.feed(b"!|*|S0103|B0K0K0K0K|1I0K0K01000bar\r\n");
1155        assert_eq!((px(&t, 20, 20), px(&t, 21, 20), px(&t, 29, 22), px(&t, 30, 22)), (3 ^ 9, 9, 9, 0));
1156        // Too near the edge: not drawn.
1157        t.feed(b"!|1IHN0000000BAR\r\n");
1158        assert_eq!(px(&t, 636, 0), 0);
1159        // A missing icon is a dashed red box.
1160        t.feed(b"!|*|1I0A0A00000NONE\r\n");
1161        assert_eq!((px(&t, 10, 10), px(&t, 12, 10), px(&t, 10, 47)), (12, 0, 12));
1162        // File queries.
1163        let _ = t.take_replies();
1164        t.feed(b"!|1F000000BAR.ICN|1F010000nope.icn|1F020000bar.icn\r\n");
1165        assert_eq!(t.take_replies(), b"10\r\n1.28\r\n");
1166        t.feed(b"!|1F040000BAR.ICN|1F000000../x\r\n");
1167        let r = String::from_utf8(t.take_replies()).unwrap();
1168        assert!(r.starts_with("1.BAR.ICN.28.") && r.ends_with("\r\n") && r.len() == 13 + 17 + 2, "{r}");
1169        // A scene file plays inline, text and graphics, and what follows
1170        // comes after it.
1171        std::fs::write(dir.join("RIP/HELLO.RIP"), b"!|c0E|L00000500\r\nfrom the scene\r\n").unwrap();
1172        t.feed(b"!|*|1R00000000hello.rip\r\nafter\r\n");
1173        assert_eq!(px(&t, 3, 0), 14);
1174        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "from the scene");
1175        assert_eq!(Screen::row_text(t.screen.row(1)).trim_end(), "after");
1176        // One that plays itself stops.
1177        std::fs::write(dir.join("RIP/LOOP.RIP"), b"!|1R00000000LOOP.RIP\r\n").unwrap();
1178        t.feed(b"!|1R00000000LOOP.RIP\r\n");
1179        let _ = std::fs::remove_dir_all(&dir);
1180        assert_eq!(
1181            rip_time(std::time::UNIX_EPOCH + std::time::Duration::from_secs(951_782_400 + 3725)),
1182            "02/29/00.01:02:05"
1183        );
1184        assert_eq!(file_name(b"a/b"), None);
1185        assert_eq!(file_name(b" x.icn "), Some("x.icn".into()));
1186    }
1187
1188    #[test]
1189    fn answers_the_rip_query() {
1190        let mut t = term(b"\x1b[!");
1191        assert_eq!(t.take_replies(), VERSION_REPLY);
1192        assert_eq!(ega_rgb(63), 0xFFFFFF);
1193        assert_eq!(ega_rgb(20), 0xAA5500);
1194    }
1195}