Skip to main content

badterm_core/
skypix.rs

1//! Amiga BBSes with SkyPix (SkyLine's, later C-Net's): ANSI text and
2//! drawing commands (`ESC [ n ; ... !`) on a 640x200 screen in 8 or 16
3//! colors, as the "ANSI/SkyPix Documentation" (v1.12, 1994) describes them.
4//!
5//! Like the Amiga's screen, everything (text included) is drawn into one
6//! bitmap of pens, which the front end shows like RIPscrip's graphics.
7//! Text is in Topaz 8, at a pixel position: ANSI moves it a cell (8x8) at a
8//! time, `ESC [ 19 ; x ; y !` anywhere. Amiga disk fonts (`ESC [ 10`) are
9//! the user's own, from the fonts folder (see [`crate::amigafont`]), or
10//! else Topaz scaled to about their size; either is drawn without a
11//! background, as the Amiga draws fonts other than the console's.
12//!
13//! ANSI colors pick the pens that look like them in SkyPix's palette.
14//! Brushes (IFF ILBM) and samples (IFF 8SVX, or raw) come by XMODEM
15//! (`ESC [ 16`, run by the session; see [`Skypix::load`]), and samples
16//! play with `ESC [ 9`.
17
18use std::time::{Duration, Instant};
19
20use crate::palette::Rgb;
21
22static TOPAZ: &[u8; 2048] = include_bytes!("../assets/topaz_8x8.bin");
23
24pub const WIDTH: usize = 640;
25pub const HEIGHT: usize = 200;
26/// The audio channel samples play on.
27const SAMPLE_CHANNEL: usize = 2;
28/// The rate of a sample with no header to say.
29const SAMPLE_RATE: u32 = 8363;
30
31pub const COLS: usize = 80;
32pub const ROWS: usize = 25;
33
34/// SkyPix's standard palette, 0x0RGB.
35const PALETTE: [u16; 16] =
36    [0x000, 0x11F, 0xDDD, 0xF00, 0x0F1, 0x3AF, 0xFF2, 0xC0E, 0x0B6, 0x0DD, 0x0AF, 0x07C, 0x00F, 0x70F, 0xC0E, 0xC08];
37/// The pens ANSI's colors (black, red, green, yellow, blue, magenta, cyan,
38/// white) are drawn in.
39const ANSI_PENS: [u8; 8] = [0, 3, 4, 6, 1, 7, 5, 2];
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42enum State {
43    Ground,
44    Escape,
45    Csi,
46    /// A string after a command's `!`, up to the next `!`.
47    Text,
48}
49
50/// `ESC [ 16`: a file the BBS sends by XMODEM-CRC.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Transfer {
53    /// 1 an IFF brush, 2 an IFF sample, 3 a FutureSound sample, 20 any.
54    pub mode: u32,
55    pub name: String,
56}
57
58/// A brush: a piece of the screen, by pen.
59struct Brush {
60    w: usize,
61    h: usize,
62    pixels: Vec<u8>,
63}
64
65pub struct Skypix {
66    pixels: Vec<u8>,
67    palette: [u16; 16],
68    /// 8 or 16 colors.
69    colors: u8,
70    /// The text cursor (top left of the next character), in pixels.
71    x: i32,
72    y: i32,
73    saved: (i32, i32),
74    /// Pen A (foreground, and what's drawn with) and B (background).
75    pen_a: u8,
76    pen_b: u8,
77    reverse: bool,
78    /// The drawing pen.
79    pen: (i32, i32),
80    /// A disk font's scale; None for Topaz (drawn with its background).
81    font: Option<i32>,
82    /// The disk font itself, when the user has it.
83    disk: Option<crate::amigafont::AmigaFont>,
84    /// Where disk fonts are looked for.
85    pub fonts_dir: Option<std::path::PathBuf>,
86    /// The sample sent last, for `ESC [ 9`.
87    sample: Option<crate::audio::Sound>,
88    /// Sound to play.
89    pub audio: Vec<crate::audio::AudioOp>,
90    /// An XMODEM transfer the BBS started, and what came after it.
91    pub transfer: Option<(Transfer, Vec<u8>)>,
92    starting: Option<Transfer>,
93    brush: Option<Brush>,
94    gadgets: Vec<(i32, i32, i32, i32)>,
95    pub cursor_on: bool,
96    state: State,
97    params: Vec<u32>,
98    /// Which parameters had digits (`ESC [ 15 ; !` has none).
99    digits: Vec<bool>,
100    have: bool,
101    text: Vec<u8>,
102    /// Bytes not read yet, while a delay (`14`) lasts.
103    held: Vec<u8>,
104    pub hold: Option<Instant>,
105    pub replies: Vec<u8>,
106    pub bells: usize,
107}
108
109impl Default for Skypix {
110    fn default() -> Skypix {
111        Skypix {
112            pixels: vec![0; WIDTH * HEIGHT],
113            palette: PALETTE,
114            // The documentation says 8, but BBSes use all 16 from the start.
115            colors: 16,
116            x: 0,
117            y: 0,
118            saved: (0, 0),
119            pen_a: 2,
120            pen_b: 0,
121            reverse: false,
122            pen: (0, 0),
123            font: None,
124            disk: None,
125            fonts_dir: None,
126            sample: None,
127            audio: Vec::new(),
128            transfer: None,
129            starting: None,
130            brush: None,
131            gadgets: Vec::new(),
132            cursor_on: true,
133            state: State::Ground,
134            params: Vec::new(),
135            digits: Vec::new(),
136            have: false,
137            text: Vec::new(),
138            held: Vec::new(),
139            hold: None,
140            replies: Vec::new(),
141            bells: 0,
142        }
143    }
144}
145
146impl Skypix {
147    pub fn pixels(&self) -> &[u8] {
148        &self.pixels
149    }
150
151    pub fn palette(&self) -> [Rgb; 16] {
152        std::array::from_fn(|i| {
153            let c = self.palette[if self.colors == 8 { i & 7 } else { i }] as u32;
154            let ch = |shift: u32| ((c >> shift) & 15) * 0x11;
155            (ch(8) << 16) | (ch(4) << 8) | ch(0)
156        })
157    }
158
159    /// The text cursor, as a cell of the 80x25 screen.
160    pub fn cursor(&self) -> (usize, usize) {
161        ((self.x.max(0) / 8).min(COLS as i32 - 1) as usize, (self.y.max(0) / 8).min(ROWS as i32 - 1) as usize)
162    }
163
164    fn mask(&self) -> u8 {
165        self.colors - 1
166    }
167
168    pub fn feed(&mut self, data: &[u8]) {
169        if self.hold.is_some() {
170            self.held.extend_from_slice(data);
171            return;
172        }
173        for (i, &b) in data.iter().enumerate() {
174            self.byte(b);
175            if self.hold.is_some() {
176                self.held.extend_from_slice(&data[i + 1..]);
177                return;
178            }
179            // A transfer: the rest is the protocol's.
180            if let Some(t) = self.starting.take() {
181                self.transfer = Some((t, data[i + 1..].to_vec()));
182                return;
183            }
184        }
185    }
186
187    /// A file an `ESC [ 16` transfer brought: a brush, or a sample.
188    pub fn load(&mut self, mode: u32, data: &[u8]) {
189        match mode {
190            1 => match crate::iff::ilbm(data) {
191                Some((w, h, pixels)) => self.brush = Some(Brush { w, h, pixels }),
192                None => log::warn!("SkyPix: the brush isn't an IFF picture"),
193            },
194            2 | 3 => {
195                self.sample = Some(crate::audio::decode(data).unwrap_or_else(|| crate::audio::raw8(data, SAMPLE_RATE)))
196            }
197            _ => {}
198        }
199    }
200
201    /// How far the text cursor moves after `c`.
202    fn advance(&self, c: u8) -> i32 {
203        match &self.disk {
204            Some(f) => f.advance(c),
205            None => self.cell().0,
206        }
207    }
208
209    /// Carry on after a delay, once its time has come.
210    pub fn poll(&mut self, now: Instant) {
211        if self.hold.is_some_and(|t| now >= t) {
212            self.hold = None;
213            let held = std::mem::take(&mut self.held);
214            self.feed(&held);
215        }
216    }
217
218    fn byte(&mut self, b: u8) {
219        match self.state {
220            State::Ground => self.ground(b),
221            State::Escape => {
222                self.state = if b == b'[' { State::Csi } else { State::Ground };
223                self.params.clear();
224                self.digits.clear();
225                self.have = false;
226            }
227            State::Csi => match b {
228                b'0'..=b'9' => {
229                    if self.params.is_empty() {
230                        self.params.push(0);
231                        self.digits.push(false);
232                    }
233                    if let Some(d) = self.digits.last_mut() {
234                        *d = true;
235                    }
236                    let p = self.params.last_mut().unwrap();
237                    *p = p.saturating_mul(10).saturating_add((b - b'0') as u32);
238                    self.have = true;
239                }
240                b';' => {
241                    if self.params.is_empty() {
242                        self.params.push(0);
243                        self.digits.push(false);
244                    }
245                    if self.params.len() < 32 {
246                        self.params.push(0);
247                        self.digits.push(false);
248                    }
249                }
250                b'!' => {
251                    // Commands with a string: the font's name, a file's.
252                    if matches!(self.params.first(), Some(0 | 10 | 16)) && !self.closes_font() {
253                        self.text.clear();
254                        self.state = State::Text;
255                    } else {
256                        self.state = State::Ground;
257                        self.command(&[]);
258                    }
259                }
260                0x40..=0x7E => {
261                    self.state = State::Ground;
262                    self.csi(b);
263                }
264                0x20..=0x3F => {}
265                _ => self.state = State::Ground,
266            },
267            State::Text => {
268                if b == b'!' || self.text.len() > 256 {
269                    self.state = State::Ground;
270                    let text = std::mem::take(&mut self.text);
271                    self.command(&text);
272                } else {
273                    self.text.push(b);
274                }
275            }
276        }
277    }
278
279    /// `ESC [ 10 ; 0 !` goes back to Topaz, with no name after it.
280    fn closes_font(&self) -> bool {
281        self.params.first() == Some(&10) && self.params.get(1).copied().unwrap_or(0) == 0
282    }
283
284    /// Whether parameter `i` had digits.
285    fn given(&self, i: usize) -> bool {
286        self.digits.get(i).copied().unwrap_or(false)
287    }
288
289    fn p(&self, i: usize) -> i32 {
290        self.params.get(i).copied().unwrap_or(0) as i32
291    }
292
293    fn ground(&mut self, b: u8) {
294        let (cw, ch) = self.cell();
295        match b {
296            0x07 => self.bells += 1,
297            0x08 => self.x = (self.x - cw).max(0),
298            0x09 => self.x = ((self.x / 64 + 1) * 64).min(WIDTH as i32 - cw),
299            0x0A => {
300                // The Amiga's LF is a new line (to its start); a disk font
301                // goes down a line on CR instead, and ignores LF.
302                if self.font.is_none() {
303                    self.x = 0;
304                    self.line_feed();
305                }
306            }
307            0x0C => self.clear(),
308            0x0D => {
309                self.x = 0;
310                if self.font.is_some() {
311                    self.line_feed();
312                }
313            }
314            0x1B => self.state = State::Escape,
315            0x00..=0x1F => {}
316            _ => {
317                // Topaz wraps; a disk font is cut off at the edge.
318                if self.x + cw > WIDTH as i32 && self.font.is_none() {
319                    self.x = 0;
320                    self.line_feed();
321                }
322                self.glyph(b);
323                self.x += self.advance(b);
324            }
325        }
326        let _ = ch;
327    }
328
329    /// The character cell: Topaz's 8x8, or a disk font's scaled one.
330    fn cell(&self) -> (i32, i32) {
331        if let Some(f) = &self.disk {
332            return (f.advance(b'n').max(1), f.height as i32);
333        }
334        let s = self.font.unwrap_or(1);
335        (8 * s, 8 * s)
336    }
337
338    fn line_feed(&mut self) {
339        let ch = self.cell().1;
340        self.y += ch;
341        if self.y + ch > HEIGHT as i32 {
342            let up = (self.y + ch - HEIGHT as i32) as usize;
343            self.scroll(up);
344            self.y -= up as i32;
345        }
346    }
347
348    fn scroll(&mut self, rows: usize) {
349        let rows = rows.min(HEIGHT);
350        self.pixels.copy_within(rows * WIDTH.., 0);
351        let bg = self.pen_b;
352        self.pixels[(HEIGHT - rows) * WIDTH..].fill(bg);
353    }
354
355    fn clear(&mut self) {
356        let bg = self.pen_b;
357        self.pixels.fill(bg);
358        (self.x, self.y) = (0, 0);
359    }
360
361    fn set(&mut self, x: i32, y: i32, pen: u8) {
362        if x >= 0 && y >= 0 && (x as usize) < WIDTH && (y as usize) < HEIGHT {
363            self.pixels[y as usize * WIDTH + x as usize] = pen & self.mask();
364        }
365    }
366
367    fn get(&self, x: i32, y: i32) -> Option<u8> {
368        (x >= 0 && y >= 0 && (x as usize) < WIDTH && (y as usize) < HEIGHT)
369            .then(|| self.pixels[y as usize * WIDTH + x as usize])
370    }
371
372    /// A character at the cursor: with its background in Topaz, without
373    /// in a disk font.
374    fn glyph(&mut self, c: u8) {
375        if let Some(f) = self.disk.take() {
376            let fg = if self.reverse { self.pen_b } else { self.pen_a };
377            for (x, y) in f.pixels(c) {
378                self.set(self.x + x, self.y + y, fg);
379            }
380            self.disk = Some(f);
381            return;
382        }
383        let s = self.font.unwrap_or(1);
384        let (fg, bg) = if self.reverse { (self.pen_b, self.pen_a) } else { (self.pen_a, self.pen_b) };
385        for gy in 0..8 * s {
386            let bits = TOPAZ[c as usize * 8 + (gy / s) as usize];
387            for gx in 0..8 * s {
388                let on = bits & (0x80 >> (gx / s)) != 0;
389                if on {
390                    self.set(self.x + gx, self.y + gy, fg);
391                } else if self.font.is_none() {
392                    self.set(self.x + gx, self.y + gy, bg);
393                }
394            }
395        }
396    }
397
398    fn csi(&mut self, f: u8) {
399        let (cw, ch) = self.cell();
400        let n = self.p(0).max(1);
401        match f {
402            b'A' => self.y = (self.y - n * ch).max(0),
403            b'B' => self.y = (self.y + n * ch).min(HEIGHT as i32 - ch),
404            b'C' => self.x = (self.x + n * cw).min(WIDTH as i32 - cw),
405            b'D' => self.x = (self.x - n * cw).max(0),
406            b'H' | b'f' => {
407                let (row, col) = (self.p(0).max(1), self.p(1).max(1));
408                self.x = ((col - 1) * 8).min(WIDTH as i32 - 8);
409                self.y = ((row - 1) * 8).min(HEIGHT as i32 - 8);
410            }
411            b'J' => match self.p(0) {
412                0 => {
413                    self.fill_rect(self.x, self.y, WIDTH as i32 - 1, self.y + ch - 1, self.pen_b);
414                    self.fill_rect(0, self.y + ch, WIDTH as i32 - 1, HEIGHT as i32 - 1, self.pen_b);
415                }
416                1 => {
417                    self.fill_rect(0, 0, WIDTH as i32 - 1, self.y - 1, self.pen_b);
418                    self.fill_rect(0, self.y, self.x + cw - 1, self.y + ch - 1, self.pen_b);
419                }
420                // The whole screen, and home, as ANSI-BBS does.
421                _ => self.clear(),
422            },
423            b'K' => {
424                let (a, b) = match self.p(0) {
425                    0 => (self.x, WIDTH as i32 - 1),
426                    1 => (0, self.x + cw - 1),
427                    _ => (0, WIDTH as i32 - 1),
428                };
429                self.fill_rect(a, self.y, b, self.y + ch - 1, self.pen_b);
430            }
431            b'm' => self.sgr(),
432            b's' => self.saved = (self.x, self.y),
433            b'u' => (self.x, self.y) = self.saved,
434            b'n' if self.p(0) == 6 => {
435                let reply = format!("\x1b[{};{}R", self.y / 8 + 1, self.x / 8 + 1);
436                self.replies.extend_from_slice(reply.as_bytes());
437            }
438            b'h' | b'l' if self.params.first() == Some(&25) => self.cursor_on = f == b'h',
439            _ => {}
440        }
441    }
442
443    fn sgr(&mut self) {
444        if self.params.is_empty() {
445            self.params.push(0);
446        }
447        for i in 0..self.params.len() {
448            match self.params[i] {
449                0 => {
450                    self.pen_a = 2;
451                    self.pen_b = 0;
452                    self.reverse = false;
453                }
454                7 => self.reverse = true,
455                27 => self.reverse = false,
456                p @ 30..=37 => self.pen_a = ANSI_PENS[(p - 30) as usize],
457                39 => self.pen_a = 2,
458                p @ 40..=47 => self.pen_b = ANSI_PENS[(p - 40) as usize],
459                49 => self.pen_b = 0,
460                _ => {}
461            }
462        }
463    }
464
465    fn fill_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, pen: u8) {
466        let (x0, x1) = (x0.min(x1).max(0), x0.max(x1).min(WIDTH as i32 - 1));
467        let (y0, y1) = (y0.min(y1).max(0), y0.max(y1).min(HEIGHT as i32 - 1));
468        for y in y0..=y1 {
469            for x in x0..=x1 {
470                self.set(x, y, pen);
471            }
472        }
473    }
474
475    fn line(&mut self, (x0, y0): (i32, i32), (x1, y1): (i32, i32)) {
476        let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
477        let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
478        let (mut x, mut y, mut err) = (x0, y0, dx + dy);
479        loop {
480            self.set(x, y, self.pen_a);
481            if x == x1 && y == y1 {
482                break;
483            }
484            let e2 = 2 * err;
485            if e2 >= dy {
486                err += dy;
487                x += sx;
488            }
489            if e2 <= dx {
490                err += dx;
491                y += sy;
492            }
493        }
494    }
495
496    /// An ellipse around (cx, cy) with radii a and b: its outline, or filled.
497    fn ellipse(&mut self, cx: i32, cy: i32, a: i32, b: i32, filled: bool) {
498        let (a, b) = (a.max(0), b.max(0));
499        if a == 0 || b == 0 {
500            self.line((cx - a, cy - b), (cx + a, cy + b));
501            return;
502        }
503        let pen = self.pen_a;
504        let half = |dy: i32| (a as f64 * (1.0 - (dy as f64 / b as f64).powi(2)).max(0.0).sqrt()).round() as i32;
505        let mut prev = half(-b);
506        for dy in -b..=b {
507            let w = half(dy);
508            if filled {
509                for x in cx - w..=cx + w {
510                    self.set(x, cy + dy, pen);
511                }
512            } else {
513                // Each side from the row above's edge to this one's, so
514                // the outline has no gaps where it's steep or flat.
515                let (near, far) = (w.min(prev), w.max(prev));
516                for x in near..=far {
517                    self.set(cx - x, cy + dy, pen);
518                    self.set(cx + x, cy + dy, pen);
519                }
520            }
521            prev = w;
522        }
523    }
524
525    /// Flood fill in pen A: mode 0 up to pixels in pen A (the outline),
526    /// mode 1 over the color under (x, y).
527    fn flood(&mut self, mode: i32, x: i32, y: i32) {
528        let Some(under) = self.get(x, y) else { return };
529        let pen = self.pen_a & self.mask();
530        let fills = |p: u8| if mode == 0 { p != pen } else { p == under };
531        if mode != 0 && under == pen || !fills(under) {
532            return;
533        }
534        let mut stack = vec![(x, y)];
535        while let Some((x, y)) = stack.pop() {
536            if !self.get(x, y).is_some_and(fills) {
537                continue;
538            }
539            let (mut a, mut b) = (x, x);
540            while self.get(a - 1, y).is_some_and(fills) {
541                a -= 1;
542            }
543            while self.get(b + 1, y).is_some_and(fills) {
544                b += 1;
545            }
546            for i in a..=b {
547                self.set(i, y, pen);
548            }
549            for i in a..=b {
550                for ny in [y - 1, y + 1] {
551                    if self.get(i, ny).is_some_and(fills) {
552                        stack.push((i, ny));
553                    }
554                }
555            }
556        }
557    }
558
559    /// A SkyPix command (`ESC [ n ; ... !`, and its string if it has one).
560    fn command(&mut self, text: &[u8]) {
561        let p = |i: usize| self.p(i);
562        match p(0) {
563            1 => self.set(p(1), p(2), self.pen_a),
564            2 => {
565                let to = (p(1), p(2));
566                self.line(self.pen, to);
567                self.pen = to;
568            }
569            3 => self.flood(p(1), p(2), p(3)),
570            4 => self.fill_rect(p(1), p(2), p(3), p(4), self.pen_a),
571            5 => self.ellipse(p(1), p(2), p(3), p(4), false),
572            6 => {
573                let (x, y, w, h) = (p(1), p(2), p(3).clamp(0, WIDTH as i32), p(4).clamp(0, HEIGHT as i32));
574                let pixels = (0..h)
575                    .flat_map(|j| (0..w).map(move |i| (x + i, y + j)))
576                    .map(|(x, y)| self.get(x, y).unwrap_or(0))
577                    .collect();
578                self.brush = Some(Brush { w: w as usize, h: h as usize, pixels });
579            }
580            7 => self.blit(p(1), p(2), p(3), p(4), p(5), p(6), p(7) as u8, p(8) as u8),
581            8 => self.pen = (p(1), p(2)),
582            10 => {
583                let name = String::from_utf8_lossy(text).to_ascii_lowercase();
584                let size = p(1);
585                // Topaz 8 (and back to it) is the console's own font.
586                let font = if size == 0 || name.starts_with("topaz") && size <= 9 {
587                    None
588                } else {
589                    Some(((size + 4) / 8).max(1))
590                };
591                // Back to Topaz, the text stays on the same baseline.
592                let was = self.cell().1;
593                if font.is_none() && self.font.is_some() {
594                    self.y = (self.y + was - 8).min(HEIGHT as i32 - 8);
595                }
596                self.font = font;
597                // The font itself, if the user has it.
598                self.disk = match (&self.fonts_dir, font) {
599                    (Some(dir), Some(_)) => crate::amigafont::AmigaFont::find(dir, &name, size.max(1) as usize),
600                    _ => None,
601                };
602            }
603            11 => {
604                for i in 0..16 {
605                    self.palette[i] = (self.p(i + 1) as u16) & 0xFFF;
606                }
607            }
608            12 => self.palette = PALETTE,
609            13 => self.ellipse(p(1), p(2), p(3), p(4), true),
610            14 => self.hold = Some(Instant::now() + Duration::from_millis(p(1).clamp(0, 6000) as u64 * 1000 / 60)),
611            // With no number, the default pen.
612            15 if !self.given(1) => self.pen_a = 2,
613            15 => self.pen_a = p(1) as u8 & 15,
614            9 => {
615                let loops = p(4).clamp(1, 20);
616                if let Some(sound) = self.sample.clone() {
617                    use crate::audio::AudioOp;
618                    self.audio.push(AudioOp::Flush { channel: SAMPLE_CHANNEL, fade_out: 0 });
619                    for _ in 0..loops {
620                        self.audio.push(AudioOp::Queue {
621                            channel: SAMPLE_CHANNEL,
622                            sound: sound.clone(),
623                            fade_in: 0,
624                            fade_out: 0,
625                            crossfade: false,
626                            looped: false,
627                            volume: (0.0, 0.0),
628                        });
629                    }
630                }
631            }
632            // A file by XMODEM-CRC, to a name the session makes safe.
633            16 => {
634                let name = String::from_utf8_lossy(text).trim().to_string();
635                let mode = p(1).max(0) as u32;
636                if matches!(mode, 1 | 2 | 3 | 20) && !name.is_empty() {
637                    self.starting = Some(Transfer { mode, name });
638                } else {
639                    self.replies.extend_from_slice(&[0x18, 0x18, 0x18]);
640                }
641            }
642            17 => {
643                self.colors = if p(1) == 2 { 16 } else { 8 };
644                self.palette = PALETTE;
645            }
646            18 if !self.given(1) => self.pen_b = 0,
647            18 => self.pen_b = p(1) as u8 & 15,
648            19 => (self.x, self.y) = (p(1).clamp(0, WIDTH as i32 - 1), p(2).clamp(0, HEIGHT as i32 - 1)),
649            22 => {
650                let (x0, y0, x1, y1) = (p(3), p(4), p(5), p(6));
651                for (a, b) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))] {
652                    self.line(a, b);
653                }
654                self.gadgets.push((x0, y0, x1, y1));
655            }
656            // The end of SkyPix: back to plain ANSI's look.
657            99 => {
658                self.palette = PALETTE;
659                self.font = None;
660                self.pen_a = 2;
661                self.pen_b = 0;
662            }
663            _ => {}
664        }
665    }
666
667    /// Copy the brush (a piece of it) to the screen, through the Amiga
668    /// blitter's minterm (192 copies) and plane mask.
669    #[allow(clippy::too_many_arguments)]
670    fn blit(&mut self, sx: i32, sy: i32, dx: i32, dy: i32, w: i32, h: i32, minterm: u8, mask: u8) {
671        let Some(brush) = self.brush.take() else { return };
672        for j in 0..h.max(0) {
673            for i in 0..w.max(0) {
674                let (bx, by) = (sx + i, sy + j);
675                if bx < 0 || by < 0 || bx as usize >= brush.w || by as usize >= brush.h {
676                    continue;
677                }
678                let b = brush.pixels[by as usize * brush.w + bx as usize];
679                let Some(c) = self.get(dx + i, dy + j) else { continue };
680                let mut out = 0u8;
681                for plane in 0..4 {
682                    let (bb, cb) = ((b >> plane) & 1, (c >> plane) & 1);
683                    let bit = if mask & (1 << plane) != 0 { (minterm >> (4 + (bb << 1 | cb))) & 1 } else { cb };
684                    out |= bit << plane;
685                }
686                self.set(dx + i, dy + j, out);
687            }
688        }
689        self.brush = Some(brush);
690    }
691
692    /// A left click at a point of the screen: SkyPix's controller return.
693    pub fn click(&self, x: i32, y: i32) -> Vec<u8> {
694        format!("\x1b[21;1;{x};{y}!").into_bytes()
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    fn sky(data: &[u8]) -> Skypix {
703        let mut s = Skypix::default();
704        s.feed(data);
705        s
706    }
707
708    fn at(s: &Skypix, x: usize, y: usize) -> u8 {
709        s.pixels[y * WIDTH + x]
710    }
711
712    #[test]
713    fn disk_fonts_brushes_and_samples() {
714        // A disk font from the fonts folder: proportional, no background.
715        let dir = std::env::temp_dir().join(format!("badterm-skyfont-{}", std::process::id()));
716        std::fs::create_dir_all(dir.join("tiny")).unwrap();
717        std::fs::write(dir.join("tiny/4"), crate::amigafont::tests::sample_font()).unwrap();
718        let mut s = Skypix { fonts_dir: Some(dir.clone()), ..Skypix::default() };
719        s.feed(b"\x1b[18;1!\x1b[10;4!tiny.font!\x1b[19;100;50!AIA");
720        // 'A' 3 wide then advance 4, 'I' at 104 advancing 2, 'A' at 106.
721        assert_eq!(
722            (at(&s, 100, 50), at(&s, 103, 50), at(&s, 104, 50), at(&s, 105, 50), at(&s, 106, 53)),
723            (2, 0, 2, 0, 2)
724        );
725        let _ = std::fs::remove_dir_all(&dir);
726        // A transfer stops reading; the protocol's bytes are kept.
727        let mut s = sky(b"\x1b[16;1;4;2!brush.iff!PROTOCOL");
728        let (t, rest) = s.transfer.take().unwrap();
729        assert_eq!((t.mode, t.name.as_str(), rest.as_slice()), (1, "brush.iff", &b"PROTOCOL"[..]));
730        // The brush it brings is blitted.
731        let mut f = b"FORM\0\0\0\0ILBM".to_vec();
732        let bmhd = [0, 2, 0, 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 10, 11, 0, 2, 0, 1];
733        for (id, d) in [(b"BMHD", &bmhd[..]), (b"BODY", &[0x40, 0, 0xC0, 0][..])] {
734            f.extend(id);
735            f.extend((d.len() as u32).to_be_bytes());
736            f.extend(d);
737        }
738        s.load(1, &f);
739        s.feed(b"\x1b[7;0;0;10;10;2;1;192;255!");
740        assert_eq!((at(&s, 10, 10), at(&s, 11, 10)), (2, 3));
741        // A sample plays, looped as asked.
742        s.load(2, &[0, 64, 127, 64, 0, 192, 128, 192]);
743        s.feed(b"\x1b[9;0;0;0;3!");
744        assert_eq!(s.audio.len(), 4);
745        // A bad mode is refused.
746        assert_eq!(sky(b"\x1b[16;9;0;0!x!").replies, [0x18; 3]);
747    }
748
749    #[test]
750    fn drawing_commands() {
751        // Red (pen 3): a pixel, a line from the pen, a box; then a filled
752        // ellipse in green.
753        let s =
754            sky(b"\x1b[15;3!\x1b[1;5;5!\x1b[8;10;10!\x1b[2;20;10!\x1b[4;30;30;40;40!\x1b[15;4!\x1b[13;100;100;10;5!");
755        assert_eq!(at(&s, 5, 5), 3);
756        assert_eq!((at(&s, 10, 10), at(&s, 20, 10), at(&s, 21, 10)), (3, 3, 0));
757        assert_eq!(at(&s, 35, 35), 3);
758        assert_eq!((at(&s, 100, 100), at(&s, 109, 100), at(&s, 112, 100)), (4, 4, 0));
759    }
760
761    #[test]
762    fn flood_fill_modes() {
763        // An outline in pen 3, then fill inside it up to the outline.
764        let mut s = sky(b"\x1b[15;3!\x1b[5;50;50;20;10!\x1b[3;0;50;50!");
765        assert_eq!((at(&s, 50, 50), at(&s, 75, 50)), (3, 0));
766        // Mode 1: repaint the color under the point.
767        s.feed(b"\x1b[15;4!\x1b[3;1;50;50!");
768        assert_eq!(at(&s, 50, 50), 4);
769    }
770
771    #[test]
772    fn ansi_text_in_pens() {
773        // ANSI red is pen 3; the text's cell is drawn in pen B.
774        let s = sky(b"\x1b[31;45mA\x1b[2;1HB");
775        let pens: Vec<u8> = (0..8).flat_map(|y| (0..8).map(move |x| (x, y))).map(|(x, y)| at(&s, x, y)).collect();
776        assert!(pens.contains(&3) && pens.contains(&7), "{pens:?}");
777        assert!((8..16).any(|y| (0..8).any(|x| at(&s, x, y) == 3)), "B on the next row");
778        assert_eq!(s.cursor(), (1, 1));
779    }
780
781    #[test]
782    fn fonts_position_and_brushes() {
783        // A disk font at a pixel position: bigger, no background.
784        let s = sky(b"\x1b[18;1!\x1b[10;16!garnet.font!\x1b[19;100;50!I");
785        assert!((50..66).any(|y| (100..116).any(|x| at(&s, x, y) == 2)));
786        assert_eq!(at(&s, 100, 50), 0, "no background drawn");
787        assert_eq!(s.cursor(), (14, 6));
788        // Grab a brush and copy it elsewhere.
789        let s = sky(b"\x1b[15;5!\x1b[4;0;0;3;3!\x1b[6;0;0;4;4!\x1b[7;0;0;200;100;4;4;192;255!");
790        assert_eq!((at(&s, 202, 102), at(&s, 205, 102)), (5, 0));
791        // The palette: 0xRGB, red highest.
792        let s = sky(b"\x1b[11;3840;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0!");
793        assert_eq!(s.palette()[0], 0xFF0000);
794    }
795}