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//! Text variables (see [`crate::ripvars`]): the built-in ones and the
26//! user's own fill in what buttons and mouse areas send, and the BBS's
27//! queries (`1 ESC`); `1D` defines one, asking the user when it must.
28//!
29//! Block-mode file transfers (`9 ESC`) are started by the session (see
30//! [`BlockTransfer`]): files come to (and go from) the RIP folder.
31
32use std::path::{Path, PathBuf};
33
34use crate::palette::Rgb;
35use crate::screen::{Cell, Screen};
36
37pub const WIDTH: usize = 640;
38pub const HEIGHT: usize = 350;
39/// The text screen over the canvas: 80x43 in the 8x8 font.
40pub const COLS: usize = 80;
41pub const ROWS: usize = 43;
42
43/// What a RIP terminal answers to `ESC [ !`: RIPscrip, version 1.54.
44pub const VERSION_REPLY: &[u8] = b"RIPSCRIP015400";
45
46/// The user's saved text variables, in the BBS's RIP folder.
47const VARS_FILE: &str = "variables.ini";
48
49/// How deep scenes may play other scenes.
50const MAX_SCENE_DEPTH: usize = 4;
51
52/// The largest icon file read, or scene played.
53const MAX_FILE: u64 = 4 << 20;
54
55/// What RIP needs from outside: where its files are, and a way to answer.
56pub struct Io<'a> {
57    /// The BBS's file cache; RIP's own files are in `RIP` in it.
58    pub cache: Option<&'a Path>,
59    /// Where downloads go, also searched for icons and scenes.
60    pub downloads: Option<&'a Path>,
61    pub replies: &'a mut Vec<u8>,
62}
63
64impl Io<'_> {
65    fn rip_dir(&self) -> Option<PathBuf> {
66        Some(self.cache?.join("RIP"))
67    }
68
69    /// An existing file, by name in any case, in the RIP folder or the
70    /// downloads.
71    fn find(&self, name: &str) -> Option<PathBuf> {
72        let dirs = [self.rip_dir(), self.downloads.map(Path::to_path_buf)];
73        dirs.into_iter().flatten().find_map(|dir| {
74            let exact = dir.join(name);
75            if exact.is_file() {
76                return Some(exact);
77            }
78            std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path()).find(|p| {
79                p.is_file() && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.eq_ignore_ascii_case(name))
80            })
81        })
82    }
83}
84
85/// A file name the BBS gave, if it is only a name: no folders, no `..`.
86fn file_name(text: &[u8]) -> Option<String> {
87    let name = std::str::from_utf8(text).ok()?.trim();
88    let bad = |c: char| matches!(c, '/' | '\\' | ':' | '\0') || c.is_control();
89    (!name.is_empty() && !name.contains("..") && !name.chars().any(bad)).then(|| name.to_string())
90}
91
92/// An icon file: BGI's `getimage` layout, 16 colors in four planes.
93fn decode_icon(data: &[u8]) -> Option<(usize, usize, Vec<u8>)> {
94    let w = u16::from_le_bytes([*data.first()?, *data.get(1)?]) as usize + 1;
95    let h = u16::from_le_bytes([*data.get(2)?, *data.get(3)?]) as usize + 1;
96    let row = w.div_ceil(8);
97    let body = data.get(4..4 + row * 4 * h)?;
98    let mut px = Vec::with_capacity(w * h);
99    for planes in body.chunks_exact(row * 4) {
100        for x in 0..w {
101            let bit = |p: usize| (planes[p * row + x / 8] >> (7 - (x & 7))) & 1;
102            px.push(bit(0) << 3 | bit(1) << 2 | bit(2) << 1 | bit(3));
103        }
104    }
105    Some((w, h, px))
106}
107
108fn encode_icon(w: usize, h: usize, px: &[u8]) -> Vec<u8> {
109    let row = w.div_ceil(8);
110    let mut out = Vec::with_capacity(4 + row * 4 * h);
111    out.extend_from_slice(&(w.saturating_sub(1) as u16).to_le_bytes());
112    out.extend_from_slice(&(h.saturating_sub(1) as u16).to_le_bytes());
113    for line in px.chunks_exact(w.max(1)).take(h) {
114        let mut planes = vec![0u8; row * 4];
115        for (x, &c) in line.iter().enumerate() {
116            for p in 0..4 {
117                if c >> (3 - p) & 1 != 0 {
118                    planes[p * row + x / 8] |= 0x80 >> (x & 7);
119                }
120            }
121        }
122        out.extend_from_slice(&planes);
123    }
124    out
125}
126
127/// A file's time as RIP gives it, `mm/dd/yy.hh:mm:ss` (local time).
128fn rip_time(t: std::time::SystemTime) -> String {
129    chrono::DateTime::<chrono::Local>::from(t).format("%m/%d/%y.%H:%M:%S").to_string()
130}
131
132/// The longest RIP line kept, continuations included.
133const MAX_LINE: usize = 64 * 1024;
134
135static FONT_8X8: &[u8; 2048] = include_bytes!("../assets/vga_8x8.bin");
136
137/// The EGA's default palette: indices into its 64 colors.
138const DEFAULT_PALETTE: [u8; 16] = [0, 1, 2, 3, 4, 5, 20, 7, 56, 57, 58, 59, 60, 61, 62, 63];
139
140/// BGI's fill patterns, 8x8, most significant bit leftmost.
141const FILL_PATTERNS: [[u8; 8]; 12] = [
142    [0x00; 8],
143    [0xFF; 8],
144    [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00],
145    [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80],
146    [0xE0, 0xC1, 0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70],
147    [0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0x87, 0xC3, 0xE1],
148    [0xA5, 0xD2, 0x69, 0xB4, 0x5A, 0x2D, 0x96, 0x4B],
149    [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88],
150    [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81],
151    [0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33, 0xCC, 0x33],
152    [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00],
153    [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00],
154];
155
156/// BGI's line styles: solid, dotted, center and dashed.
157const LINE_STYLES: [u16; 4] = [0xFFFF, 0xCCCC, 0xFC78, 0xF8F8];
158
159/// The EGA draws circles this much flatter, so they look round on a 4:3
160/// screen of 640x350 (BGI's aspect ratio for it).
161const ASPECT: f64 = 0.775;
162
163/// One of the EGA's 64 colors (bits: secondary R G B, primary R G B).
164pub fn ega_rgb(c: u8) -> Rgb {
165    let level = |hi: u8, lo: u8| (((c >> hi) & 1) * 0xAA + ((c >> lo) & 1) * 0x55) as u32;
166    (level(2, 5) << 16) | (level(1, 4) << 8) | level(0, 3)
167}
168
169/// A clickable area: when clicked, `text` is sent to the BBS.
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct MouseRegion {
172    pub x0: usize,
173    pub y0: usize,
174    pub x1: usize,
175    pub y1: usize,
176    /// Show the area inverted while it is clicked.
177    pub invert: bool,
178    pub text: Vec<u8>,
179}
180
181#[derive(Clone, Copy, Debug, Default)]
182struct ButtonStyle {
183    width: usize,
184    height: usize,
185    bevel: usize,
186    dfore: u8,
187    dback: u8,
188    bright: u8,
189    dark: u8,
190    surface: u8,
191}
192
193#[derive(Clone, Copy, Debug)]
194struct Viewport {
195    x0: usize,
196    y0: usize,
197    x1: usize,
198    y1: usize,
199}
200
201const FULL: Viewport = Viewport { x0: 0, y0: 0, x1: WIDTH - 1, y1: HEIGHT - 1 };
202
203/// A text block being laid out (`1T` ... `1E`).
204struct TextBlock {
205    x0: i32,
206    x1: i32,
207    y: i32,
208}
209
210pub struct Rip {
211    pixels: Vec<u8>,
212    /// Palette entry -> EGA color.
213    palette: [u8; 16],
214    color: u8,
215    fill_color: u8,
216    fill: [u8; 8],
217    line_style: u16,
218    thick: bool,
219    xor: bool,
220    view: Viewport,
221    /// The drawing position (for text), relative to the viewport.
222    pos: (i32, i32),
223    font_size: usize,
224    vertical: bool,
225    button: ButtonStyle,
226    regions: Vec<MouseRegion>,
227    clipboard: Option<(usize, usize, Vec<u8>)>,
228    text_block: Option<TextBlock>,
229    /// User text variables, the saved ones read in once.
230    vars: std::collections::BTreeMap<String, String>,
231    vars_loaded: bool,
232    /// Where the graphics were last clicked, for `$X$` and the like.
233    mouse: Option<(usize, usize)>,
234    /// A query to answer when the graphics are clicked outside buttons.
235    deferred: Option<Vec<u8>>,
236    /// A question for the user (taken by [`Rip::take_question`]), and what
237    /// to do with the answer.
238    question: Option<Question>,
239    pending: Option<Pending>,
240    /// `$BEEP$` and the other sounds: ring the bell.
241    pub bell: bool,
242    /// A file transfer the BBS started, for the session.
243    pub block: Option<BlockTransfer>,
244    /// The text window, in text cells (x0, y0, x1, y1).
245    text_window: (usize, usize, usize, usize),
246    /// The RIP line being read, from its `!`.
247    line: Option<Vec<u8>>,
248    /// A RIP line just ended with CR: its LF goes too.
249    pub(crate) eat_lf: bool,
250    /// A scene file (`1R`) to play before what follows.
251    scene: Option<Vec<u8>>,
252    /// How many scenes are playing inside each other.
253    pub scene_depth: usize,
254    /// Changed since the front end last looked.
255    pub dirty: bool,
256}
257
258/// `RIP_ENTER_BLOCK_MODE`: the BBS starts a file transfer.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct BlockTransfer {
261    pub upload: bool,
262    /// 0-3: XMODEM (checksum, CRC, 1K, 1K-G), 4 Kermit, 5 YMODEM, 6
263    /// YMODEM-G, 7 ZMODEM.
264    pub protocol: u8,
265    /// 0: RIP files to show; 1-4: files to keep (RIP, icons, help...); 5:
266    /// to keep and show.
267    pub file_type: u8,
268    /// The file's name, safe to use (needed for XMODEM, Kermit uploads).
269    pub name: Option<String>,
270}
271
272impl BlockTransfer {
273    /// RIP files received are played as they arrive.
274    pub fn shows(&self) -> bool {
275        matches!(self.file_type, 0 | 5)
276    }
277}
278
279/// Something to ask the user for a text variable.
280#[derive(Clone, Debug, PartialEq, Eq)]
281pub struct Question {
282    pub name: String,
283    pub prompt: String,
284    /// What the answer starts as.
285    pub value: String,
286    pub width: usize,
287}
288
289/// What an answer to a [`Question`] is for.
290#[derive(Debug)]
291enum Pending {
292    /// `1D`: keep it (saved to disk if `save`); `required`: not blank.
293    Define { name: String, save: bool, required: bool, question: Question },
294    /// A query or host command waiting for the variable: send it after.
295    Send { name: String, text: Vec<u8> },
296}
297
298/// What [`Rip::byte`] did with a byte.
299pub enum Feed {
300    /// Not RIP: handle it as text.
301    Pass,
302    /// Taken.
303    Done,
304    /// It turned out not to be a RIP line: show these bytes as text.
305    Text(Vec<u8>),
306}
307
308impl Default for Rip {
309    fn default() -> Self {
310        Rip {
311            pixels: vec![0; WIDTH * HEIGHT],
312            palette: DEFAULT_PALETTE,
313            color: 15,
314            fill_color: 15,
315            fill: FILL_PATTERNS[1],
316            line_style: 0xFFFF,
317            thick: false,
318            xor: false,
319            view: FULL,
320            pos: (0, 0),
321            font_size: 1,
322            vertical: false,
323            button: ButtonStyle::default(),
324            regions: Vec::new(),
325            clipboard: None,
326            text_block: None,
327            vars: Default::default(),
328            vars_loaded: false,
329            mouse: None,
330            deferred: None,
331            question: None,
332            pending: None,
333            bell: false,
334            block: None,
335            text_window: (0, 0, COLS - 1, ROWS - 1),
336            line: None,
337            eat_lf: false,
338            scene: None,
339            scene_depth: 0,
340            dirty: true,
341        }
342    }
343}
344
345impl Rip {
346    /// The canvas: `WIDTH` x `HEIGHT` palette indices.
347    pub fn pixels(&self) -> &[u8] {
348        &self.pixels
349    }
350
351    /// The colors of the 16 palette entries.
352    pub fn palette(&self) -> [Rgb; 16] {
353        self.palette.map(ega_rgb)
354    }
355
356    pub fn regions(&self) -> &[MouseRegion] {
357        &self.regions
358    }
359
360    /// A click on the canvas at (x, y): what it sends, with its text
361    /// variables filled in. A mouse region's command, or else a query
362    /// waiting for a click (`1 ESC`, modes 1 and 2). None if nothing, or if
363    /// the user must answer a question first (see [`Rip::take_question`]).
364    pub fn press(&mut self, s: &mut Screen, x: usize, y: usize, io: &mut Io) -> Option<Vec<u8>> {
365        self.mouse = Some((x, y));
366        let text = self.click(x, y).or_else(|| self.deferred.clone())?;
367        self.expand_or_ask(s, text, io)
368    }
369
370    pub fn has_question(&self) -> bool {
371        self.question.is_some()
372    }
373
374    /// The question for the user, if a text variable needs one.
375    pub fn take_question(&mut self) -> Option<Question> {
376        self.question.take()
377    }
378
379    /// The user's answer to the last question (None: cancelled).
380    pub fn answer(&mut self, s: &mut Screen, answer: Option<String>, io: &mut Io) {
381        let Some(pending) = self.pending.take() else { return };
382        let Some(answer) = answer else { return };
383        match pending {
384            Pending::Define { name, save, required, question } => {
385                if required && answer.trim().is_empty() {
386                    self.ask(question.clone(), Pending::Define { name, save, required, question });
387                    return;
388                }
389                self.set_var(&name, &answer, save, io);
390            }
391            Pending::Send { name, text } => {
392                self.set_var(&name, &answer, false, io);
393                if let Some(out) = self.expand_or_ask(s, text, io) {
394                    io.replies.extend(out);
395                }
396            }
397        }
398    }
399
400    fn ask(&mut self, q: Question, pending: Pending) {
401        self.question = Some(q);
402        self.pending = Some(pending);
403    }
404
405    fn load_vars(&mut self, io: &Io) {
406        if !self.vars_loaded {
407            self.vars_loaded = true;
408            if let Some(dir) = io.rip_dir() {
409                self.vars = crate::ripvars::load(&dir.join(VARS_FILE));
410            }
411        }
412    }
413
414    fn set_var(&mut self, name: &str, value: &str, save: bool, io: &Io) {
415        self.vars.insert(name.to_string(), value.to_string());
416        if save && let Some(dir) = io.rip_dir() {
417            let path = dir.join(VARS_FILE);
418            let mut saved = crate::ripvars::load(&path);
419            saved.insert(name.to_string(), value.to_string());
420            if let Err(e) = crate::ripvars::save(&path, &saved) {
421                log::warn!("saving RIP variables: {e}");
422            }
423        }
424    }
425
426    /// `text` with its variables filled in; if one is the user's and not
427    /// yet known, None, and a question for the user, after which the text
428    /// is sent.
429    fn expand_or_ask(&mut self, s: &mut Screen, text: Vec<u8>, io: &mut Io) -> Option<Vec<u8>> {
430        match self.expand(s, &text, io) {
431            Ok(out) => Some(out),
432            Err(name) => {
433                let q =
434                    Question { prompt: format!("Enter {name}"), name: name.clone(), value: String::new(), width: 60 };
435                self.ask(q, Pending::Send { name, text });
436                None
437            }
438        }
439    }
440
441    /// Fill in `$NAME$`s: built-in values, actions (done, and gone), and
442    /// user variables; Err with the name of one not yet known.
443    fn expand(&mut self, s: &mut Screen, text: &[u8], io: &mut Io) -> Result<Vec<u8>, String> {
444        self.load_vars(io);
445        let mut out = Vec::with_capacity(text.len());
446        let mut i = 0;
447        while i < text.len() {
448            if text[i] == b'$'
449                && let Some(len) = text[i + 1..].iter().position(|&c| c == b'$')
450            {
451                let name = String::from_utf8_lossy(&text[i + 1..i + 1 + len]).to_ascii_uppercase();
452                if crate::ripvars::is_name(&name) {
453                    let value = match crate::ripvars::builtin(&name, self.mouse) {
454                        Some(v) => Some(v),
455                        None if self.action(s, &name) => Some(String::new()),
456                        None => self.vars.get(&name).cloned(),
457                    };
458                    match value {
459                        Some(v) => out.extend_from_slice(v.as_bytes()),
460                        None => return Err(name),
461                    }
462                    i += len + 2;
463                    continue;
464                }
465            }
466            out.push(text[i]);
467            i += 1;
468        }
469        Ok(out)
470    }
471
472    /// The active variables: do it, and say whether `name` is one.
473    fn action(&mut self, s: &mut Screen, name: &str) -> bool {
474        match name {
475            "RESET" => self.reset(s),
476            "EGW" => {
477                let v = self.view;
478                for y in v.y0..=v.y1 {
479                    self.pixels[y * WIDTH + v.x0..=y * WIDTH + v.x1].fill(0);
480                }
481            }
482            "ETW" => {
483                let (x0, y0, x1, y1) = self.text_window;
484                s.fill_rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, Cell { ch: b' ', ..s.pen });
485                s.move_to(x0, y0);
486            }
487            "MKILL" => self.regions.clear(),
488            "BEEP" | "BLIP" | "MUSIC" | "ALARM" | "PHASER" | "REVPHASER" => self.bell = true,
489            "CON" => s.cursor_visible = true,
490            "COFF" => s.cursor_visible = false,
491            // The rest (saving screens, the status bar, keyboard modes...)
492            // do nothing here, but aren't the user's.
493            "SAVEALL" | "RESTOREALL" | "SAVE" | "RESTORE" | "SMF" | "RMF" | "DTW" | "STW" | "RTW" | "SCB" | "RCB"
494            | "PCB" | "SBARON" | "SBAROFF" | "VT102ON" | "VT102OFF" | "DWAYON" | "DWAYOFF" | "HKEYON" | "HKEYOFF"
495            | "TABON" | "TABOFF" | "OFF" => {}
496            n if n.starts_with("SAVE") || n.starts_with("RESTORE") || n.starts_with("APP") => {}
497            _ => return false,
498        }
499        self.dirty = true;
500        true
501    }
502
503    /// What clicking the canvas at (x, y) sends, if it is in a mouse region
504    /// (the last defined wins, as they are drawn over each other).
505    pub fn click(&self, x: usize, y: usize) -> Option<Vec<u8>> {
506        self.regions
507            .iter()
508            .rev()
509            .find(|r| (r.x0..=r.x1).contains(&x) && (r.y0..=r.y1).contains(&y))
510            .map(|r| r.text.clone())
511    }
512
513    /// Take a byte at the text level: `at_line_start` says whether it
514    /// begins a line, where a RIP line may start.
515    pub fn byte(&mut self, s: &mut Screen, b: u8, at_line_start: bool, io: &mut Io) -> Feed {
516        if std::mem::take(&mut self.eat_lf) && b == b'\n' {
517            return Feed::Done;
518        }
519        let Some(line) = self.line.as_mut() else {
520            if at_line_start && b == b'!' {
521                self.line = Some(vec![b]);
522                return Feed::Done;
523            }
524            return Feed::Pass;
525        };
526        // `!` must be followed by `|`; otherwise it was text.
527        if line.len() == 1 && b != b'|' {
528            let mut text = self.line.take().unwrap_or_default();
529            text.push(b);
530            return Feed::Text(text);
531        }
532        match b {
533            b'\r' | b'\n' => {
534                // A backslash before the end of the line continues it.
535                if line.last() == Some(&b'\\') && !line.ends_with(b"\\\\") {
536                    line.pop();
537                    line.push(0);
538                    return Feed::Done;
539                }
540                if line.last() == Some(&0) {
541                    // The LF of a continued line's CR LF.
542                    return Feed::Done;
543                }
544                let line = self.line.take().unwrap_or_default();
545                self.run_line(s, &line, io);
546                // The line's end is not shown: RIP lines are invisible.
547                self.eat_lf = b == b'\r';
548                Feed::Done
549            }
550            _ => {
551                if line.last() == Some(&0) {
552                    line.pop();
553                }
554                if line.len() < MAX_LINE {
555                    line.push(b);
556                }
557                Feed::Done
558            }
559        }
560    }
561
562    /// A scene file a `1R` asked for: the caller plays it as if it came
563    /// from the BBS, before the rest of what did.
564    pub fn take_scene(&mut self) -> Option<Vec<u8>> {
565        self.scene.take()
566    }
567
568    fn run_line(&mut self, s: &mut Screen, line: &[u8], io: &mut Io) {
569        // Split on unescaped bars; the first piece is the `!`.
570        let mut cmds: Vec<Vec<u8>> = vec![Vec::new()];
571        let mut i = 1;
572        while i < line.len() {
573            match line[i] {
574                b'\\' if i + 1 < line.len() => {
575                    cmds.last_mut().expect("never empty").push(line[i + 1]);
576                    i += 1;
577                }
578                b'|' => cmds.push(Vec::new()),
579                c => cmds.last_mut().expect("never empty").push(c),
580            }
581            i += 1;
582        }
583        for cmd in cmds.iter().filter(|c| !c.is_empty()) {
584            self.command(s, cmd, io);
585        }
586        self.dirty = true;
587    }
588
589    fn command(&mut self, s: &mut Screen, cmd: &[u8], io: &mut Io) {
590        let (level, op, args) = match cmd {
591            [d @ b'1'..=b'9', op, args @ ..] => (d - b'0', *op, args),
592            [op, args @ ..] => (0, *op, args),
593            [] => return,
594        };
595        let mut a = Args(args);
596        match (level, op) {
597            (0, b'w') => {
598                let (x0, y0, x1, y1) = (a.n2(), a.n2(), a.n2(), a.n2());
599                let (x1, y1) = (x1.clamp(x0, COLS - 1), y1.clamp(y0, ROWS - 1));
600                self.text_window = (x0.min(COLS - 1), y0.min(ROWS - 1), x1, y1);
601                s.set_scroll_region(self.text_window.1, self.text_window.3);
602                s.move_to(self.text_window.0, self.text_window.1);
603            }
604            (0, b'v') => {
605                let (x0, y0, x1, y1) = (a.n2(), a.n2(), a.n2(), a.n2());
606                self.view = Viewport {
607                    x0: x0.min(WIDTH - 1),
608                    y0: y0.min(HEIGHT - 1),
609                    x1: x1.clamp(x0.min(WIDTH - 1), WIDTH - 1),
610                    y1: y1.clamp(y0.min(HEIGHT - 1), HEIGHT - 1),
611                };
612            }
613            (0, b'*') => self.reset(s),
614            (0, b'e') => {
615                let (x0, y0, x1, y1) = self.text_window;
616                s.fill_rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, Cell { ch: b' ', ..s.pen });
617                s.move_to(x0, y0);
618            }
619            (0, b'E') => {
620                let v = self.view;
621                for y in v.y0..=v.y1 {
622                    self.pixels[y * WIDTH + v.x0..=y * WIDTH + v.x1].fill(0);
623                }
624            }
625            (0, b'g') => {
626                let (x, y) = (a.n2(), a.n2());
627                s.move_to(self.text_window.0 + x, self.text_window.1 + y);
628            }
629            (0, b'H') => s.move_to(self.text_window.0, self.text_window.1),
630            (0, b'>') => s.erase_line(0),
631            (0, b'c') => self.color = a.n2() as u8 & 15,
632            (0, b'Q') => {
633                for i in 0..16 {
634                    self.palette[i] = a.n2() as u8 & 63;
635                }
636            }
637            (0, b'a') => {
638                let (i, c) = (a.n2(), a.n2());
639                self.palette[i & 15] = c as u8 & 63;
640            }
641            (0, b'W') => self.xor = a.n2() == 1,
642            (0, b'm') => self.pos = (a.i2(), a.i2()),
643            (0, b'T') => {
644                let text = a.rest().to_vec();
645                self.text(&text);
646            }
647            (0, b'@') => {
648                self.pos = (a.i2(), a.i2());
649                let text = a.rest().to_vec();
650                self.text(&text);
651            }
652            (0, b'Y') => {
653                let (_font, dir, size) = (a.n2(), a.n2(), a.n2());
654                self.vertical = dir == 1;
655                self.font_size = size.clamp(1, 10);
656            }
657            (0, b'X') => {
658                let (x, y) = (a.i2(), a.i2());
659                self.plot(x, y, self.color);
660            }
661            (0, b'L') => {
662                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
663                self.line(x0, y0, x1, y1);
664            }
665            (0, b'R') => {
666                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
667                self.polyline(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)], true);
668            }
669            (0, b'B') => {
670                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
671                self.bar(x0, y0, x1, y1);
672            }
673            (0, b'C') => {
674                let (x, y, r) = (a.i2(), a.i2(), a.i2());
675                let pts = arc_points(x, y, 0, 360, r as f64, r as f64 * ASPECT);
676                self.polyline(&pts, false);
677            }
678            (0, b'O') => {
679                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
680                let pts = arc_points(x, y, st, end, xr as f64, yr as f64);
681                self.polyline(&pts, false);
682            }
683            (0, b'o') => {
684                let (x, y, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2());
685                let pts = arc_points(x, y, 0, 360, xr as f64, yr as f64);
686                self.fill_polygon(&pts);
687                self.polyline(&pts, false);
688            }
689            (0, b'A') => {
690                let (x, y, st, end, r) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
691                let pts = arc_points(x, y, st, end, r as f64, r as f64 * ASPECT);
692                self.polyline(&pts, false);
693            }
694            (0, b'V') => {
695                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
696                let pts = arc_points(x, y, st, end, xr as f64, yr as f64);
697                self.polyline(&pts, false);
698            }
699            (0, b'I') => {
700                let (x, y, st, end, r) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
701                self.pie(x, y, st, end, r as f64, r as f64 * ASPECT);
702            }
703            (0, b'i') => {
704                let (x, y, st, end, xr, yr) = (a.i2(), a.i2(), a.i2(), a.i2(), a.i2(), a.i2());
705                self.pie(x, y, st, end, xr as f64, yr as f64);
706            }
707            (0, b'Z') => {
708                let p: Vec<(f64, f64)> = (0..4).map(|_| (a.i2() as f64, a.i2() as f64)).collect();
709                let n = a.n2().max(1);
710                let pts: Vec<(i32, i32)> = (0..=n)
711                    .map(|i| {
712                        let t = i as f64 / n as f64;
713                        let u = 1.0 - t;
714                        let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
715                        let x = b0 * p[0].0 + b1 * p[1].0 + b2 * p[2].0 + b3 * p[3].0;
716                        let y = b0 * p[0].1 + b1 * p[1].1 + b2 * p[2].1 + b3 * p[3].1;
717                        (x.round() as i32, y.round() as i32)
718                    })
719                    .collect();
720                self.polyline(&pts, false);
721            }
722            (0, b'P' | b'p' | b'l') => {
723                let n = a.n2();
724                let pts: Vec<(i32, i32)> = (0..n).map(|_| (a.i2(), a.i2())).collect();
725                if op == b'p' {
726                    self.fill_polygon(&pts);
727                }
728                self.polyline(&pts, op != b'l');
729            }
730            (0, b'F') => {
731                let (x, y, border) = (a.i2(), a.i2(), a.n2() as u8);
732                self.flood(x, y, border & 15);
733            }
734            (0, b'=') => {
735                let (style, user, thick) = (a.n2(), a.n4(), a.n2());
736                self.line_style = LINE_STYLES.get(style).copied().unwrap_or(user as u16);
737                self.thick = thick >= 3;
738            }
739            (0, b'S') => {
740                let (pattern, color) = (a.n2(), a.n2());
741                self.fill = FILL_PATTERNS.get(pattern).copied().unwrap_or(FILL_PATTERNS[1]);
742                self.fill_color = color as u8 & 15;
743            }
744            (0, b's') => {
745                for row in self.fill.iter_mut() {
746                    *row = a.n2() as u8;
747                }
748                self.fill_color = a.n2() as u8 & 15;
749            }
750            (0, b'#') => {}
751            (1, b'M') => {
752                let (_num, x0, y0, x1, y1, clk, _clr) = (a.n2(), a.n2(), a.n2(), a.n2(), a.n2(), a.n1(), a.n1());
753                a.skip(5);
754                let text = host_command(a.rest());
755                self.region(x0, y0, x1, y1, clk == 1, text);
756            }
757            (1, b'K') => self.regions.clear(),
758            (1, b'T') => {
759                let (x0, y0, x1) = (a.i2(), a.i2(), a.i2());
760                self.text_block = Some(TextBlock { x0, x1, y: y0 });
761            }
762            (1, b't') => {
763                let _justify = a.n1();
764                let text = a.rest().to_vec();
765                if let Some(tb) = self.text_block.as_mut() {
766                    let (x0, x1, y) = (tb.x0, tb.x1, tb.y);
767                    tb.y += 8 * self.font_size as i32;
768                    let room = ((x1 - x0).max(0) as usize / (8 * self.font_size)).max(1);
769                    self.pos = (x0, y);
770                    self.text(&text[..text.len().min(room)]);
771                }
772            }
773            (1, b'E') => self.text_block = None,
774            (1, b'C') => {
775                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
776                self.copy_to_clipboard(x0, y0, x1, y1);
777            }
778            (1, b'P') => {
779                let (x, y, mode) = (a.i2(), a.i2(), a.n2());
780                self.paste(x, y, mode);
781            }
782            (1, b'G') => {
783                let (x0, y0, x1, y1) = (a.i2(), a.i2(), a.i2(), a.i2());
784                a.skip(2);
785                let dest = a.i2();
786                self.copy_rows(x0, y0, x1, y1, dest);
787            }
788            (1, b'B') => {
789                self.button = ButtonStyle {
790                    width: a.n2(),
791                    height: a.n2(),
792                    bevel: {
793                        // The orientation and flags (icons, hot keys,
794                        // radio groups) are not used.
795                        a.skip(6);
796                        a.n2()
797                    },
798                    dfore: a.n2() as u8 & 15,
799                    dback: a.n2() as u8 & 15,
800                    bright: a.n2() as u8 & 15,
801                    dark: a.n2() as u8 & 15,
802                    surface: a.n2() as u8 & 15,
803                };
804            }
805            (1, b'U') => {
806                let (x0, y0, x1, y1, _hotkey, _flags) = (a.i2(), a.i2(), a.i2(), a.i2(), a.n2(), a.n1());
807                a.skip(1);
808                let text = a.rest().to_vec();
809                self.draw_button(x0, y0, x1, y1, &text);
810            }
811            (1, b'I') => {
812                let (x, y, mode, clip) = (a.i2(), a.i2(), a.n2(), a.n1() == 1);
813                a.skip(2);
814                if let Some(name) = file_name(a.rest()) {
815                    let name = if name.contains('.') { name } else { format!("{name}.ICN") };
816                    self.load_icon(x, y, mode, clip, &name, io);
817                }
818            }
819            (1, b'W') => {
820                a.skip(1);
821                if let (Some(name), Some((w, h, px))) = (file_name(a.rest()), &self.clipboard) {
822                    let data = encode_icon(*w, *h, px);
823                    let saved = io.rip_dir().ok_or_else(|| anyhow::anyhow!("no file cache")).and_then(|dir| {
824                        std::fs::create_dir_all(&dir)?;
825                        Ok(std::fs::write(dir.join(&name), data)?)
826                    });
827                    if let Err(e) = saved {
828                        log::warn!("RIP icon {name}: {e:#}");
829                    }
830                }
831            }
832            (1, b'R') => {
833                a.skip(8);
834                let found = file_name(a.rest()).and_then(|n| io.find(&n));
835                match found.map(std::fs::read) {
836                    _ if self.scene_depth >= MAX_SCENE_DEPTH => log::warn!("RIP scenes nested too deep"),
837                    Some(Ok(data)) if data.len() as u64 <= MAX_FILE => self.scene = Some(data),
838                    _ => log::info!("RIP scene {:?} not found", String::from_utf8_lossy(a.rest())),
839                }
840            }
841            (9, 0x1B) => {
842                let (mode, protocol, file_type) = (a.n1(), a.n1(), a.n2());
843                a.skip(4);
844                let rest = a.rest();
845                let name = rest.strip_suffix(b"<>").unwrap_or(rest);
846                self.block = Some(BlockTransfer {
847                    upload: mode == 1,
848                    protocol: protocol.min(7) as u8,
849                    file_type: file_type.min(5) as u8,
850                    name: file_name(name),
851                });
852            }
853            (1, b'D') => {
854                let flags = a.digits(3);
855                a.skip(2);
856                let Some(d) = crate::ripvars::Define::parse(a.rest()) else { return };
857                self.load_vars(io);
858                let now = self.vars.get(&d.name).cloned();
859                let (save, required, quiet) = (flags & 1 != 0, flags & 2 != 0, flags & 4 != 0);
860                // Not asking: a known variable takes the default, if any.
861                if quiet && now.is_some() {
862                    if !d.default.is_empty() {
863                        self.set_var(&d.name, &d.default, save, io);
864                    }
865                    return;
866                }
867                let value = if d.default.is_empty() { now.unwrap_or_default() } else { d.default.clone() };
868                let q = Question { name: d.name.clone(), prompt: d.prompt(), value, width: d.width };
869                self.ask(q.clone(), Pending::Define { name: d.name, save, required, question: q });
870            }
871            (1, 0x1B) => {
872                let mode = a.n1();
873                a.skip(3);
874                let text = host_command(a.rest());
875                if text.eq_ignore_ascii_case(b"$OFF$") {
876                    self.deferred = None;
877                } else if mode == 0 {
878                    if let Some(out) = self.expand_or_ask(s, text, io) {
879                        io.replies.extend(out);
880                    }
881                } else {
882                    self.deferred = Some(text);
883                }
884            }
885            (1, b'F') => {
886                let mode = a.n2();
887                a.skip(4);
888                let Some(name) = file_name(a.rest()) else { return };
889                let meta = io.find(&name).and_then(|p| std::fs::metadata(p).ok());
890                let reply = match (mode, meta) {
891                    (0, m) => if m.is_some() { "1" } else { "0" }.to_string(),
892                    (1, m) => if m.is_some() { "1\r\n" } else { "0\r\n" }.to_string(),
893                    (_, None) => "0\r\n".to_string(),
894                    (2, Some(m)) => format!("1.{}\r\n", m.len()),
895                    (3, Some(m)) => {
896                        format!("1.{}.{}\r\n", m.len(), rip_time(m.modified().unwrap_or(std::time::UNIX_EPOCH)))
897                    }
898                    (_, Some(m)) => {
899                        let t = rip_time(m.modified().unwrap_or(std::time::UNIX_EPOCH));
900                        format!("1.{}.{}.{t}\r\n", name.to_ascii_uppercase(), m.len())
901                    }
902                };
903                io.replies.extend_from_slice(reply.as_bytes());
904            }
905            _ => log::debug!("unhandled RIP command {level}{}", op as char),
906        }
907    }
908
909    /// `RIP_RESET_WINDOWS`: everything back as it starts.
910    fn reset(&mut self, s: &mut Screen) {
911        *self = Rip {
912            line: self.line.take(),
913            eat_lf: self.eat_lf,
914            scene_depth: self.scene_depth,
915            vars: std::mem::take(&mut self.vars),
916            vars_loaded: self.vars_loaded,
917            ..Rip::default()
918        };
919        s.set_scroll_region(0, s.rows() - 1);
920        s.erase_display(2);
921        s.move_to(0, 0);
922    }
923
924    /// Set a pixel, at viewport coordinates, clipped to the viewport.
925    fn plot(&mut self, x: i32, y: i32, c: u8) {
926        let v = self.view;
927        let (x, y) = (x + v.x0 as i32, y + v.y0 as i32);
928        if x < v.x0 as i32 || y < v.y0 as i32 || x > v.x1 as i32 || y > v.y1 as i32 {
929            return;
930        }
931        let p = &mut self.pixels[y as usize * WIDTH + x as usize];
932        *p = if self.xor { *p ^ c } else { c };
933    }
934
935    fn get(&self, x: i32, y: i32) -> Option<u8> {
936        let v = self.view;
937        let (x, y) = (x + v.x0 as i32, y + v.y0 as i32);
938        (x >= v.x0 as i32 && y >= v.y0 as i32 && x <= v.x1 as i32 && y <= v.y1 as i32)
939            .then(|| self.pixels[y as usize * WIDTH + x as usize])
940    }
941
942    /// A pixel of the fill pattern: the fill color where its bit is set,
943    /// the background (0) elsewhere.
944    fn fill_at(&mut self, x: i32, y: i32) {
945        let v = self.view;
946        let bit = self.fill[(y + v.y0 as i32).rem_euclid(8) as usize] & (0x80 >> (x + v.x0 as i32).rem_euclid(8));
947        let c = if bit != 0 { self.fill_color } else { 0 };
948        self.plot(x, y, c);
949    }
950
951    /// A line in the line style, three pixels wide when thick.
952    fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
953        let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
954        let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
955        let steep = -dy > dx;
956        let (mut x, mut y, mut err) = (x0, y0, dx + dy);
957        let mut n = 0u32;
958        loop {
959            if self.line_style & (0x8000 >> (n % 16)) != 0 {
960                self.plot(x, y, self.color);
961                if self.thick {
962                    let (ox, oy) = if steep { (1, 0) } else { (0, 1) };
963                    self.plot(x - ox, y - oy, self.color);
964                    self.plot(x + ox, y + oy, self.color);
965                }
966            }
967            n += 1;
968            if x == x1 && y == y1 {
969                break;
970            }
971            let e2 = 2 * err;
972            if e2 >= dy {
973                err += dy;
974                x += sx;
975            }
976            if e2 <= dx {
977                err += dx;
978                y += sy;
979            }
980        }
981    }
982
983    fn polyline(&mut self, pts: &[(i32, i32)], closed: bool) {
984        for w in pts.windows(2) {
985            self.line(w[0].0, w[0].1, w[1].0, w[1].1);
986        }
987        if closed && pts.len() > 2 {
988            let (a, b) = (pts[pts.len() - 1], pts[0]);
989            self.line(a.0, a.1, b.0, b.1);
990        }
991    }
992
993    fn bar(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
994        for y in y0.min(y1)..=y0.max(y1) {
995            for x in x0.min(x1)..=x0.max(x1) {
996                self.fill_at(x, y);
997            }
998        }
999    }
1000
1001    /// Fill a polygon (even-odd, by scan lines) with the fill pattern.
1002    fn fill_polygon(&mut self, pts: &[(i32, i32)]) {
1003        if pts.len() < 3 {
1004            return;
1005        }
1006        let (top, bottom) = pts.iter().fold((i32::MAX, i32::MIN), |(t, b), &(_, y)| (t.min(y), b.max(y)));
1007        for y in top..=bottom {
1008            let yc = y as f64 + 0.5;
1009            let mut xs: Vec<f64> = Vec::new();
1010            for i in 0..pts.len() {
1011                let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
1012                let (ay, by) = (a.1 as f64, b.1 as f64);
1013                if (ay <= yc) != (by <= yc) {
1014                    xs.push(a.0 as f64 + (yc - ay) / (by - ay) * (b.0 - a.0) as f64);
1015                }
1016            }
1017            xs.sort_by(f64::total_cmp);
1018            for pair in xs.chunks(2) {
1019                if let [l, r] = pair {
1020                    for x in l.round() as i32..=r.round() as i32 {
1021                        self.fill_at(x, y);
1022                    }
1023                }
1024            }
1025        }
1026    }
1027
1028    fn pie(&mut self, x: i32, y: i32, st: i32, end: i32, xr: f64, yr: f64) {
1029        let mut pts = vec![(x, y)];
1030        pts.extend(arc_points(x, y, st, end, xr, yr));
1031        self.fill_polygon(&pts);
1032        self.polyline(&pts, true);
1033    }
1034
1035    /// Flood the area around (x, y) up to the border color with the fill
1036    /// pattern.
1037    fn flood(&mut self, x: i32, y: i32, border: u8) {
1038        let v = self.view;
1039        let (w, h) = ((v.x1 - v.x0 + 1) as i32, (v.y1 - v.y0 + 1) as i32);
1040        if self.get(x, y).is_none_or(|c| c == border) {
1041            return;
1042        }
1043        let mut seen = vec![false; (w * h) as usize];
1044        let mut stack = vec![(x, y)];
1045        while let Some((px, py)) = stack.pop() {
1046            if px < 0 || py < 0 || px >= w || py >= h {
1047                continue;
1048            }
1049            let i = (py * w + px) as usize;
1050            if seen[i] || self.get(px, py) == Some(border) {
1051                continue;
1052            }
1053            // Fill this run of the line, then look above and below it.
1054            let mut l = px;
1055            while l > 0 && !seen[(py * w + l - 1) as usize] && self.get(l - 1, py) != Some(border) {
1056                l -= 1;
1057            }
1058            let mut r = px;
1059            while r + 1 < w && !seen[(py * w + r + 1) as usize] && self.get(r + 1, py) != Some(border) {
1060                r += 1;
1061            }
1062            for xx in l..=r {
1063                seen[(py * w + xx) as usize] = true;
1064                self.fill_at(xx, py);
1065                stack.push((xx, py - 1));
1066                stack.push((xx, py + 1));
1067            }
1068        }
1069    }
1070
1071    /// Draw text at the drawing position in the 8x8 font, scaled by the
1072    /// font size, leaving the background; moves the position past it.
1073    fn text(&mut self, text: &[u8]) {
1074        let k = self.font_size as i32;
1075        for &ch in text {
1076            let (x0, y0) = self.pos;
1077            for gy in 0..8 {
1078                let bits = FONT_8X8[ch as usize * 8 + gy as usize];
1079                for gx in 0..8 {
1080                    if bits & (0x80 >> gx) == 0 {
1081                        continue;
1082                    }
1083                    for sy in 0..k {
1084                        for sx in 0..k {
1085                            // Vertical text runs upwards, rotated.
1086                            let (px, py) = if self.vertical {
1087                                (x0 + gy * k + sy, y0 - gx * k - sx)
1088                            } else {
1089                                (x0 + gx * k + sx, y0 + gy * k + sy)
1090                            };
1091                            self.plot(px, py, self.color);
1092                        }
1093                    }
1094                }
1095            }
1096            if self.vertical {
1097                self.pos.1 -= 8 * k;
1098            } else {
1099                self.pos.0 += 8 * k;
1100            }
1101        }
1102    }
1103
1104    fn region(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, invert: bool, text: Vec<u8>) {
1105        let (x1, y1) = (x1.max(x0).min(WIDTH - 1), y1.max(y0).min(HEIGHT - 1));
1106        self.regions.push(MouseRegion { x0, y0, x1, y1, invert, text });
1107    }
1108
1109    fn copy_to_clipboard(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
1110        let (w, h) = ((x1 - x0 + 1).max(0) as usize, (y1 - y0 + 1).max(0) as usize);
1111        let data = (0..h as i32)
1112            .flat_map(|dy| (0..w as i32).map(move |dx| (dx, dy)))
1113            .map(|(dx, dy)| self.get(x0 + dx, y0 + dy).unwrap_or(0))
1114            .collect();
1115        self.clipboard = Some((w, h, data));
1116    }
1117
1118    /// Put the clipboard at (x, y): copied, XORed, ORed, ANDed or inverted.
1119    fn paste(&mut self, x: i32, y: i32, mode: usize) {
1120        let Some((w, h, data)) = self.clipboard.clone() else { return };
1121        self.stamp(x, y, mode, w, h, &data);
1122    }
1123
1124    /// `1I`: an icon file stamped at (x, y) as [`Rip::paste`] does, and
1125    /// put on the clipboard too if asked. An icon that would not fit in
1126    /// the viewport is not drawn; a missing one is a box saying so.
1127    fn load_icon(&mut self, x: i32, y: i32, mode: usize, clip: bool, name: &str, io: &Io) {
1128        let file = io.find(name).filter(|p| std::fs::metadata(p).is_ok_and(|m| m.len() <= MAX_FILE));
1129        match file.and_then(|p| std::fs::read(p).ok()) {
1130            Some(data) => {
1131                let Some((w, h, px)) = decode_icon(&data) else {
1132                    log::warn!("RIP icon {name}: not an icon file");
1133                    return;
1134                };
1135                let v = self.view;
1136                if v.x0 + x as usize + w - 1 > v.x1 || v.y0 + y as usize + h - 1 > v.y1 {
1137                    return;
1138                }
1139                self.stamp(x, y, mode, w, h, &px);
1140                if clip {
1141                    self.clipboard = Some((w, h, px));
1142                }
1143            }
1144            None => {
1145                let label = format!("Icon {}", name.to_ascii_uppercase());
1146                let saved = (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor);
1147                (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor) =
1148                    (12, 0xCCCC, false, 1, false, false);
1149                let width = 8 * label.len().max(13) as i32;
1150                let (x1, y1) = (x + width + 24, y + 37);
1151                self.polyline(&[(x, y), (x1, y), (x1, y1), (x, y1)], true);
1152                for (text, dy) in [(label.as_bytes(), 6), (b"not available".as_slice(), 20)] {
1153                    self.pos = (x + (x1 - x + 1 - 8 * text.len() as i32) / 2, y + dy);
1154                    self.text(text);
1155                }
1156                (self.color, self.line_style, self.thick, self.font_size, self.vertical, self.xor) = saved;
1157                if clip {
1158                    self.copy_to_clipboard(x, y, x1, y1);
1159                }
1160            }
1161        }
1162    }
1163
1164    fn stamp(&mut self, x: i32, y: i32, mode: usize, w: usize, h: usize, data: &[u8]) {
1165        let xor = self.xor;
1166        self.xor = false;
1167        for dy in 0..h {
1168            for dx in 0..w {
1169                let (px, py) = (x + dx as i32, y + dy as i32);
1170                let (src, dst) = (data[dy * w + dx], self.get(px, py).unwrap_or(0));
1171                let c = match mode {
1172                    1 => dst ^ src,
1173                    2 => dst | src,
1174                    3 => dst & src,
1175                    4 => !src & 15,
1176                    _ => src,
1177                };
1178                self.plot(px, py, c);
1179            }
1180        }
1181        self.xor = xor;
1182    }
1183
1184    /// `1G`: copy lines y0..=y1 (columns x0..=x1) to start at line `dest`.
1185    fn copy_rows(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, dest: i32) {
1186        let clipboard = self.clipboard.take();
1187        self.copy_to_clipboard(x0, y0, x1, y1);
1188        self.paste(x0, dest, 0);
1189        self.clipboard = clipboard;
1190    }
1191
1192    /// `1U`: a button in the current style, with a mouse region when it has
1193    /// a host command. Its text is `icon<>label<>command`.
1194    fn draw_button(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, text: &[u8]) {
1195        let st = self.button;
1196        let (x1, y1) = if st.width > 0 && st.height > 0 {
1197            (x0 + st.width as i32 - 1, y0 + st.height as i32 - 1)
1198        } else {
1199            (x1, y1)
1200        };
1201        let mut parts = split_on(text, b"<>");
1202        let (_icon, label, command) =
1203            (parts.next().unwrap_or_default(), parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
1204        let saved = (self.fill, self.fill_color, self.color, self.line_style, self.thick, self.xor);
1205        (self.fill, self.line_style, self.thick, self.xor) = (FILL_PATTERNS[1], 0xFFFF, false, false);
1206        let b = st.bevel as i32;
1207        // The bevel: light along the top and left, dark along the others.
1208        if b > 0 {
1209            self.fill_color = st.bright;
1210            self.fill_polygon(&[(x0 - b, y0 - b), (x1 + b, y0 - b), (x1, y0), (x0, y0), (x0, y1), (x0 - b, y1 + b)]);
1211            self.fill_color = st.dark;
1212            self.fill_polygon(&[(x1 + b, y0 - b), (x1 + b, y1 + b), (x0 - b, y1 + b), (x0, y1), (x1, y1), (x1, y0)]);
1213        }
1214        self.fill_color = st.surface;
1215        self.bar(x0, y0, x1, y1);
1216        // The label, centered, with its shadow one pixel down and right.
1217        let k = self.font_size as i32;
1218        let (lw, lh) = (label.len() as i32 * 8 * k, 8 * k);
1219        let (lx, ly) = (x0 + (x1 - x0 + 1 - lw) / 2, y0 + (y1 - y0 + 1 - lh) / 2);
1220        let vertical = std::mem::replace(&mut self.vertical, false);
1221        if st.dback != st.surface {
1222            self.color = st.dback;
1223            self.pos = (lx + 1, ly + 1);
1224            self.text(label);
1225        }
1226        self.color = st.dfore;
1227        self.pos = (lx, ly);
1228        self.text(label);
1229        self.vertical = vertical;
1230        (self.fill, self.fill_color, self.color, self.line_style, self.thick, self.xor) = saved;
1231        if !command.is_empty() {
1232            let v = self.view;
1233            let at = |p: i32, o: usize| (p + o as i32).max(0) as usize;
1234            self.region(at(x0, v.x0), at(y0, v.y0), at(x1, v.x0), at(y1, v.y0), true, host_command(command));
1235        }
1236    }
1237}
1238
1239/// Points along an elliptical arc from `st` to `end` degrees, counter-
1240/// clockwise from three o'clock as in BGI.
1241fn arc_points(cx: i32, cy: i32, st: i32, end: i32, xr: f64, yr: f64) -> Vec<(i32, i32)> {
1242    let end = if end <= st { end + 360 } else { end };
1243    let steps = ((end - st) as f64 * (xr.max(yr) / 20.0).max(1.0) / 3.0).ceil().clamp(4.0, 720.0) as usize;
1244    (0..=steps)
1245        .map(|i| {
1246            let a = (st as f64 + (end - st) as f64 * i as f64 / steps as f64).to_radians();
1247            ((cx as f64 + xr * a.cos()).round() as i32, (cy as f64 - yr * a.sin()).round() as i32)
1248        })
1249        .collect()
1250}
1251
1252/// A host command's text, with `^M`-style control characters made real.
1253fn host_command(text: &[u8]) -> Vec<u8> {
1254    let mut out = Vec::with_capacity(text.len());
1255    let mut i = 0;
1256    while i < text.len() {
1257        match (text[i], text.get(i + 1)) {
1258            (b'^', Some(&c)) if (b'@'..=b'_').contains(&c.to_ascii_uppercase()) => {
1259                out.push(c.to_ascii_uppercase() - b'@');
1260                i += 2;
1261            }
1262            (c, _) => {
1263                out.push(c);
1264                i += 1;
1265            }
1266        }
1267    }
1268    out
1269}
1270
1271fn split_on<'a>(text: &'a [u8], sep: &'a [u8]) -> impl Iterator<Item = &'a [u8]> {
1272    let mut rest = Some(text);
1273    std::iter::from_fn(move || {
1274        let r = rest?;
1275        match r.windows(sep.len()).position(|w| w == sep) {
1276            Some(i) => {
1277                rest = Some(&r[i + sep.len()..]);
1278                Some(&r[..i])
1279            }
1280            None => {
1281                rest = None;
1282                Some(r)
1283            }
1284        }
1285    })
1286}
1287
1288/// A command's parameters: MegaNums (base 36) and the text after them.
1289struct Args<'a>(&'a [u8]);
1290
1291impl Args<'_> {
1292    fn digits(&mut self, n: usize) -> usize {
1293        let (head, tail) = self.0.split_at(n.min(self.0.len()));
1294        self.0 = tail;
1295        head.iter().fold(0, |v, &c| {
1296            let d = match c {
1297                b'0'..=b'9' => c - b'0',
1298                b'A'..=b'Z' => c - b'A' + 10,
1299                b'a'..=b'z' => c - b'a' + 10,
1300                _ => 0,
1301            };
1302            v * 36 + d as usize
1303        })
1304    }
1305
1306    fn n1(&mut self) -> usize {
1307        self.digits(1)
1308    }
1309
1310    fn n2(&mut self) -> usize {
1311        self.digits(2)
1312    }
1313
1314    fn n4(&mut self) -> usize {
1315        self.digits(4)
1316    }
1317
1318    fn i2(&mut self) -> i32 {
1319        self.n2() as i32
1320    }
1321
1322    fn skip(&mut self, n: usize) {
1323        self.0 = &self.0[n.min(self.0.len())..];
1324    }
1325
1326    fn rest(&self) -> &[u8] {
1327        self.0
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use crate::ansi::{Encoding, Terminal};
1335
1336    fn term(data: &[u8]) -> Terminal {
1337        let mut t = Terminal::new(COLS, ROWS, 0);
1338        t.encoding = Encoding::Rip;
1339        t.start_emulation();
1340        t.feed(data);
1341        t
1342    }
1343
1344    fn px(t: &Terminal, x: usize, y: usize) -> u8 {
1345        t.rip().expect("RIP is on").pixels()[y * WIDTH + x]
1346    }
1347
1348    #[test]
1349    fn megnums_and_escapes() {
1350        let mut a = Args(b"0A1Z00hello\\|");
1351        assert_eq!((a.n2(), a.n2(), a.n2()), (10, 71, 0));
1352        assert_eq!(host_command(b"go^m"), b"go\r");
1353        assert_eq!(split_on(b"a<>b<>c", b"<>").collect::<Vec<_>>(), [b"a" as &[u8], b"b", b"c"]);
1354    }
1355
1356    #[test]
1357    fn draws_lines_bars_and_text_beside_ansi() {
1358        // Yellow (14) line, a red bar, then text on the text screen.
1359        let t = term(b"!|c0E|L00000A00|S0104|B0A0A0F0F\r\nHello\r\n");
1360        assert_eq!((px(&t, 0, 0), px(&t, 10, 0), px(&t, 11, 0)), (14, 14, 0));
1361        assert_eq!((px(&t, 12, 12), px(&t, 16, 16)), (4, 0));
1362        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "Hello");
1363        // `!` not followed by `|` is just text.
1364        let t = term(b"!Hi\r\n");
1365        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "!Hi");
1366    }
1367
1368    #[test]
1369    fn continuation_fill_and_reset() {
1370        // A rectangle, continued over a line break, then flood its inside.
1371        let t = term(b"!|c0F|R0A0A1414|S01\\\r\n02|F0F0F0F\r\n");
1372        assert_eq!((px(&t, 10, 10), px(&t, 15, 15), px(&t, 39, 39), px(&t, 50, 50)), (15, 2, 2, 0));
1373        let t = term(b"!|c0F|L00000Z00|*\r\n");
1374        assert_eq!(px(&t, 5, 0), 0);
1375    }
1376
1377    #[test]
1378    fn buttons_and_mouse_regions() {
1379        let t = term(b"!|1B000000000002000F0F0807000000000000|1U0A0A1E140000<>OK<>yes^M\r\n");
1380        let rip = t.rip().unwrap();
1381        assert_eq!(rip.click(15, 15), Some(b"yes\r".to_vec()));
1382        assert_eq!(rip.click(100, 100), None);
1383        // The surface is light gray (7), the bevel white above and left.
1384        assert_eq!(px(&t, 11, 19), 7);
1385        assert_eq!(px(&t, 9, 12), 15);
1386        let t = term(b"!|1M00000010101100000say hi\r\n");
1387        assert_eq!(t.rip().unwrap().click(5, 5), Some(b"say hi".to_vec()));
1388        let t = term(b"!|1M00000010101100000say hi\r\n!|1K\r\n");
1389        assert_eq!(t.rip().unwrap().click(5, 5), None);
1390    }
1391
1392    #[test]
1393    fn icons_scenes_and_file_queries() {
1394        let dir = std::env::temp_dir().join(format!("badterm-rip-test-{}", std::process::id()));
1395        let _ = std::fs::remove_dir_all(&dir);
1396        let mut t = Terminal::new(COLS, ROWS, 0);
1397        t.encoding = Encoding::Rip;
1398        t.start_emulation();
1399        t.syncterm.cache_dir = Some(dir.clone());
1400        // A 10x3 bar of color 9 (odd width: planes pad to a byte), copied
1401        // and written out as an icon.
1402        t.feed(b"!|S0109|B00000902|1C00000902|1W0BAR.ICN\r\n");
1403        let icn = std::fs::read(dir.join("RIP/BAR.ICN")).unwrap();
1404        assert_eq!(icn.len(), 4 + 2 * 4 * 3);
1405        assert_eq!(decode_icon(&icn), Some((10, 3, vec![9; 30])));
1406        // Loaded elsewhere (any case, the extension added), XORed.
1407        t.feed(b"!|*|S0103|B0K0K0K0K|1I0K0K01000bar\r\n");
1408        assert_eq!((px(&t, 20, 20), px(&t, 21, 20), px(&t, 29, 22), px(&t, 30, 22)), (3 ^ 9, 9, 9, 0));
1409        // Too near the edge: not drawn.
1410        t.feed(b"!|1IHN0000000BAR\r\n");
1411        assert_eq!(px(&t, 636, 0), 0);
1412        // A missing icon is a dashed red box.
1413        t.feed(b"!|*|1I0A0A00000NONE\r\n");
1414        assert_eq!((px(&t, 10, 10), px(&t, 12, 10), px(&t, 10, 47)), (12, 0, 12));
1415        // File queries.
1416        let _ = t.take_replies();
1417        t.feed(b"!|1F000000BAR.ICN|1F010000nope.icn|1F020000bar.icn\r\n");
1418        assert_eq!(t.take_replies(), b"10\r\n1.28\r\n");
1419        t.feed(b"!|1F040000BAR.ICN|1F000000../x\r\n");
1420        let r = String::from_utf8(t.take_replies()).unwrap();
1421        assert!(r.starts_with("1.BAR.ICN.28.") && r.ends_with("\r\n") && r.len() == 13 + 17 + 2, "{r}");
1422        // A scene file plays inline, text and graphics, and what follows
1423        // comes after it.
1424        std::fs::write(dir.join("RIP/HELLO.RIP"), b"!|c0E|L00000500\r\nfrom the scene\r\n").unwrap();
1425        t.feed(b"!|*|1R00000000hello.rip\r\nafter\r\n");
1426        assert_eq!(px(&t, 3, 0), 14);
1427        assert_eq!(Screen::row_text(t.screen.row(0)).trim_end(), "from the scene");
1428        assert_eq!(Screen::row_text(t.screen.row(1)).trim_end(), "after");
1429        // One that plays itself stops.
1430        std::fs::write(dir.join("RIP/LOOP.RIP"), b"!|1R00000000LOOP.RIP\r\n").unwrap();
1431        t.feed(b"!|1R00000000LOOP.RIP\r\n");
1432        let _ = std::fs::remove_dir_all(&dir);
1433        // Local time: whatever the zone, its shape, and the minutes and
1434        // seconds (zones differ by whole quarter hours).
1435        let t = rip_time(std::time::UNIX_EPOCH + std::time::Duration::from_secs(951_782_400 + 3725));
1436        assert_eq!((t.len(), &t[2..3], &t[5..6], &t[8..9], &t[14..]), (17, "/", "/", ".", ":05"), "{t}");
1437        assert_eq!(file_name(b"a/b"), None);
1438        assert_eq!(file_name(b" x.icn "), Some("x.icn".into()));
1439    }
1440
1441    #[test]
1442    fn text_variables() {
1443        use crate::ansi::TermEvent;
1444        let dir = std::env::temp_dir().join(format!("badterm-ripvars-{}", std::process::id()));
1445        let _ = std::fs::remove_dir_all(&dir);
1446        let mut t = Terminal::new(COLS, ROWS, 0);
1447        t.encoding = Encoding::Rip;
1448        t.start_emulation();
1449        t.syncterm.cache_dir = Some(dir.clone());
1450        // A query of built-in variables, answered at once.
1451        t.feed(b"!|1\x1b0000$RIPVER$ at $XY$^m\r\n");
1452        assert_eq!(t.take_replies(), b"RIPSCRIP015400 at 0000:0000\r");
1453        // A defined variable, saved, asked about first.
1454        t.feed(b"!|1D00100NAME,20:?Your name?Ann\r\n");
1455        let q = match t.take_events().pop() {
1456            Some(TermEvent::RipQuestion(q)) => q,
1457            other => panic!("{other:?}"),
1458        };
1459        assert_eq!((q.name.as_str(), q.prompt.as_str(), q.value.as_str(), q.width), ("NAME", "Your name", "Ann", 20));
1460        t.rip_answer(Some("Bob".into()));
1461        assert!(std::fs::read_to_string(dir.join("RIP/variables.ini")).unwrap().contains("NAME=Bob"));
1462        // A button sends it, and the mouse position; the bell rings.
1463        t.feed(b"!|1M00000010101100000hi $name$ $X$$BEEP$\r\n");
1464        assert_eq!(t.rip_press(5, 7), Some(b"hi Bob 0005".to_vec()));
1465        assert_eq!(t.take_events(), [TermEvent::Bell]);
1466        // One not known yet is asked for, and the query sent after.
1467        t.feed(b"!|1\x1b0000age=$AGE$\r\n");
1468        assert!(t.take_replies().is_empty());
1469        assert!(matches!(t.take_events().pop(), Some(TermEvent::RipQuestion(q)) if q.name == "AGE"));
1470        t.rip_answer(Some("42".into()));
1471        assert_eq!(t.take_replies(), b"age=42");
1472        // A new terminal reads the saved one; quietly defined, it stays.
1473        let mut t = Terminal::new(COLS, ROWS, 0);
1474        t.encoding = Encoding::Rip;
1475        t.start_emulation();
1476        t.syncterm.cache_dir = Some(dir.clone());
1477        t.feed(b"!|1D00400NAME:\r\n!|1\x1b0000$NAME$\r\n");
1478        assert_eq!(t.take_replies(), b"Bob");
1479        // A deferred query goes with a click outside the buttons.
1480        t.feed(b"!|1\x1b1000clicked $XY$\r\n");
1481        assert_eq!(t.rip_press(300, 200), Some(b"clicked 0300:0200".to_vec()));
1482        let _ = std::fs::remove_dir_all(&dir);
1483    }
1484
1485    #[test]
1486    fn answers_the_rip_query() {
1487        let mut t = term(b"\x1b[!");
1488        assert_eq!(t.take_replies(), VERSION_REPLY);
1489        assert_eq!(ega_rgb(63), 0xFFFFFF);
1490        assert_eq!(ega_rgb(20), 0xAA5500);
1491    }
1492}