Skip to main content

badterm_core/
syncterm.rs

1//! SyncTERM's Application Program Commands (`ESC _ SyncTERM:... ESC \`),
2//! as CTerm documents them: a cache of files the BBS stores on this
3//! machine, pictures (PPM and JPEG XL, from the cache or sent inline)
4//! drawn anywhere on the screen with clipping, flips, zoom and masks, and
5//! copying and pasting pixels. Synchronet's graphical doors (SyncDoom and
6//! the like) draw with these.
7//!
8//! Pixels are drawn over the screen's cells (see
9//! [`Screen::draw_pixels`]), so text written later replaces them, as in
10//! SyncTERM.
11
12use std::path::{Path, PathBuf};
13
14use data_encoding::{BASE64, BASE64_NOPAD};
15
16use crate::screen::Screen;
17use crate::sixel::{Image, solid};
18
19/// Larger pictures are refused.
20const MAX_SIDE: usize = 4096;
21/// The most the cache for one BBS may hold.
22const MAX_CACHE: u64 = 256 << 20;
23
24/// Which pixels to draw: a bitmap, set where the source shows.
25#[derive(Clone, Debug, Default)]
26struct Mask {
27    width: usize,
28    height: usize,
29    bits: Vec<bool>,
30}
31
32impl Mask {
33    fn get(&self, x: usize, y: usize) -> bool {
34        x < self.width && y < self.height && self.bits[y * self.width + x]
35    }
36}
37
38#[derive(Default)]
39pub struct SyncTerm {
40    /// Where this BBS's files are cached; no cache without one.
41    pub cache_dir: Option<PathBuf>,
42    /// The two pixel buffers (`B=0`, `B=1`).
43    buffers: [Option<Image>; 2],
44    /// The mask buffer (`MBUF`).
45    mask: Option<Mask>,
46    /// Sound slots and what the channels are playing.
47    pub audio: crate::audio::Audio,
48    /// Sound for the front end to play.
49    pub audio_ops: Vec<crate::audio::AudioOp>,
50    /// `A;Wait`: read nothing more from the BBS until then.
51    pub hold: Option<std::time::Instant>,
52}
53
54/// An APC's fields, after `SyncTERM:X;`.
55struct Args<'a> {
56    fields: Vec<&'a str>,
57}
58
59impl<'a> Args<'a> {
60    /// `NAME=value`, as a number.
61    fn num(&self, name: &str) -> Option<i64> {
62        self.text(name).and_then(|v| v.trim().parse().ok())
63    }
64
65    fn text(&self, name: &str) -> Option<&'a str> {
66        self.fields.iter().find_map(|f| f.split_once('=').filter(|(k, _)| *k == name).map(|(_, v)| v))
67    }
68
69    /// A flag with no value (`FX`).
70    fn flag(&self, name: &str) -> bool {
71        self.fields.contains(&name)
72    }
73}
74
75pub(crate) fn decode_b64(data: &str) -> Option<Vec<u8>> {
76    let data: String = data.chars().filter(|c| !c.is_whitespace()).collect();
77    BASE64.decode(data.as_bytes()).or_else(|_| BASE64_NOPAD.decode(data.trim_end_matches('=').as_bytes())).ok()
78}
79
80/// A cache file name the BBS gave: a plain name, no folders.
81pub(crate) fn safe_name(name: &str) -> Option<&str> {
82    let ok = !name.is_empty()
83        && name.len() <= 255
84        && !name.starts_with('.')
85        && !name.chars().any(|c| matches!(c, '/' | '\\' | ':' | '\0') || c.is_control());
86    ok.then_some(name)
87}
88
89/// glob(3)'s `*` and `?`.
90fn glob(pattern: &[u8], name: &[u8]) -> bool {
91    match (pattern.first(), name.first()) {
92        (None, None) => true,
93        (Some(b'*'), _) => glob(&pattern[1..], name) || !name.is_empty() && glob(pattern, &name[1..]),
94        (Some(b'?'), Some(_)) => glob(&pattern[1..], &name[1..]),
95        (Some(p), Some(n)) if p == n => glob(&pattern[1..], &name[1..]),
96        _ => false,
97    }
98}
99
100/// Netpbm's header: the magic, then numbers, with `#` comments. Returns
101/// the numbers and where the data starts.
102fn pnm_header(data: &[u8], count: usize) -> Option<(Vec<usize>, usize)> {
103    let mut i = 2;
104    let mut nums = Vec::new();
105    while nums.len() < count {
106        while i < data.len() && (data[i].is_ascii_whitespace() || data[i] == b'#') {
107            if data[i] == b'#' {
108                while i < data.len() && data[i] != b'\n' {
109                    i += 1;
110                }
111            } else {
112                i += 1;
113            }
114        }
115        let start = i;
116        while i < data.len() && data[i].is_ascii_digit() {
117            i += 1;
118        }
119        nums.push(std::str::from_utf8(&data[start..i]).ok()?.parse().ok()?);
120    }
121    // One whitespace byte ends the header.
122    Some((nums, i + 1))
123}
124
125/// The numbers of a plain (text) Netpbm file's data.
126fn plain_numbers(data: &[u8]) -> impl Iterator<Item = usize> + '_ {
127    data.split(|b| b.is_ascii_whitespace())
128        .filter(|t| !t.is_empty())
129        .filter_map(|t| std::str::from_utf8(t).ok()?.parse().ok())
130}
131
132/// A PPM picture, raw (P6) or plain (P3).
133fn decode_ppm(data: &[u8]) -> Option<Image> {
134    let raw = match data.get(..2)? {
135        b"P6" => true,
136        b"P3" => false,
137        _ => return None,
138    };
139    let (h, start) = pnm_header(data, 3)?;
140    let (width, height, max) = (h[0], h[1], h[2].clamp(1, 255));
141    if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
142        return None;
143    }
144    let scale = |v: usize| (v.min(max) * 255 / max) as u32;
145    let n = width * height;
146    let samples: Vec<usize> = if raw {
147        data.get(start..start + n * 3)?.iter().map(|&b| b as usize).collect()
148    } else {
149        plain_numbers(data.get(start - 1..)?).take(n * 3).collect()
150    };
151    if samples.len() < n * 3 {
152        return None;
153    }
154    let pixels = samples.chunks(3).map(|c| solid(scale(c[0]) << 16 | scale(c[1]) << 8 | scale(c[2]))).collect();
155    Some(Image { width, height, pixels })
156}
157
158/// A PBM bitmap, raw (P4) or plain (P1): 1 (black) is set.
159fn decode_pbm(data: &[u8]) -> Option<Mask> {
160    let raw = match data.get(..2)? {
161        b"P4" => true,
162        b"P1" => false,
163        _ => return None,
164    };
165    let (h, start) = pnm_header(data, 2)?;
166    let (width, height) = (h[0], h[1]);
167    if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
168        return None;
169    }
170    let bits = if raw {
171        // Rows are padded to whole bytes.
172        let row = width.div_ceil(8);
173        let d = data.get(start..start + row * height)?;
174        (0..height).flat_map(|y| (0..width).map(move |x| d[y * row + x / 8] & (0x80 >> (x % 8)) != 0)).collect()
175    } else {
176        let bits: Vec<bool> = data
177            .get(start - 1..)?
178            .iter()
179            .filter(|b| matches!(b, b'0' | b'1'))
180            .take(width * height)
181            .map(|&b| b == b'1')
182            .collect();
183        if bits.len() < width * height {
184            return None;
185        }
186        bits
187    };
188    Some(Mask { width, height, bits })
189}
190
191/// A JPEG XL picture, in sRGB.
192fn decode_jxl(data: &[u8]) -> Option<Image> {
193    use jxl_oxide::{EnumColourEncoding, JxlImage, RenderingIntent};
194    let mut image = JxlImage::builder().read(std::io::Cursor::new(data)).ok()?;
195    let (width, height) = (image.width() as usize, image.height() as usize);
196    if width == 0 || height == 0 || width > MAX_SIDE || height > MAX_SIDE {
197        return None;
198    }
199    image.request_color_encoding(EnumColourEncoding::srgb(RenderingIntent::Relative));
200    let render = image.render_frame(0).ok()?;
201    let mut stream = render.stream_no_alpha();
202    let (w, h, ch) = (stream.width() as usize, stream.height() as usize, stream.channels() as usize);
203    let mut buf = vec![0u8; w * h * ch];
204    stream.write_to_buffer(&mut buf);
205    let pixels = buf
206        .chunks(ch.max(1))
207        .map(|c| match *c {
208            [r, g, b, ..] => solid((r as u32) << 16 | (g as u32) << 8 | b as u32),
209            [v, ..] => solid((v as u32) * 0x010101),
210            [] => solid(0),
211        })
212        .collect();
213    Some(Image { width: w, height: h, pixels })
214}
215
216impl SyncTerm {
217    /// Handle an APC string (without `ESC _` and `ESC \`). Replies go in
218    /// `replies`. Returns false if it isn't SyncTERM's.
219    pub fn apc(&mut self, data: &[u8], screen: &mut Screen, replies: &mut Vec<u8>) -> bool {
220        let Ok(text) = std::str::from_utf8(data) else { return false };
221        let Some(rest) = text.strip_prefix("SyncTERM:") else { return false };
222        let mut fields: Vec<&str> = rest.split(';').collect();
223        let kind = fields.remove(0);
224        let verb = if fields.is_empty() { "" } else { fields.remove(0) };
225        match (kind, verb) {
226            ("C", "S") => self.store(&fields),
227            ("C", "L") => self.list(fields.first().copied().unwrap_or("*"), replies),
228            ("C", "DrawPPM" | "DrawPPMBlob" | "DrawJXL" | "DrawJXLBlob") => {
229                let Some((&last, opts)) = fields.split_last() else { return true };
230                let args = Args { fields: opts.to_vec() };
231                if let Some(img) = self.load(verb, last) {
232                    self.draw(&img, &args, screen);
233                }
234            }
235            ("C", "LoadPPM" | "LoadPPMBlob" | "LoadJXL" | "LoadJXLBlob") => {
236                let Some((&last, opts)) = fields.split_last() else { return true };
237                let args = Args { fields: opts.to_vec() };
238                let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
239                self.buffers[b] = self.load(verb, last);
240            }
241            ("C", "LoadPBM" | "LoadPBMBlob") => {
242                let Some(&last) = fields.last() else { return true };
243                let bytes = if verb == "LoadPBMBlob" { decode_b64(last) } else { self.read_cached(last) };
244                self.mask = bytes.and_then(|b| decode_pbm(&b));
245            }
246            ("P", "Copy") => {
247                let args = Args { fields };
248                let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
249                self.buffers[b] = Some(copy(screen, &args));
250            }
251            ("P", "Paste") => {
252                let args = Args { fields };
253                let b = args.num("B").unwrap_or(0).clamp(0, 1) as usize;
254                if let Some(img) = self.buffers[b].take() {
255                    self.draw(&img, &args, screen);
256                    self.buffers[b] = Some(img);
257                }
258            }
259            // JPEG XL is always there.
260            ("Q", "JXL") => replies.extend_from_slice(b"\x1b[=1;1-n"),
261            ("A", _) => {
262                let mut all = vec![verb];
263                all.extend(&fields);
264                if let Some(until) = self.audio.apc(&all, self.cache_dir.as_ref(), &mut self.audio_ops) {
265                    // At most a minute, in case of a sound that never ends.
266                    self.hold = Some(until.min(std::time::Instant::now() + std::time::Duration::from_secs(60)));
267                }
268            }
269            // Sound files: some formats (see audio::format_supported).
270            ("Q", "libsndfile") => replies.extend_from_slice(b"\x1b[=7;100;1n"),
271            ("Q", "libsndfileFormat") => {
272                let id = |i: usize| crate::audio::parse_id(fields.get(i).copied().unwrap_or("0"));
273                let (pm, ps) = (id(0), id(1));
274                let ok = crate::audio::format_supported(pm, ps) as u8;
275                replies.extend_from_slice(format!("\x1b[=7;101;{pm};{ps};{ok}n").as_bytes());
276            }
277            _ => log::debug!("unhandled SyncTERM APC {kind};{verb}"),
278        }
279        true
280    }
281
282    /// A picture by verb: a cached file's name, or a base64 blob.
283    fn load(&self, verb: &str, arg: &str) -> Option<Image> {
284        let bytes = if verb.ends_with("Blob") { decode_b64(arg)? } else { self.read_cached(arg)? };
285        if verb.contains("JXL") { decode_jxl(&bytes) } else { decode_ppm(&bytes) }
286    }
287
288    fn cache_path(&self, name: &str) -> Option<PathBuf> {
289        Some(self.cache_dir.as_ref()?.join(safe_name(name)?))
290    }
291
292    fn read_cached(&self, name: &str) -> Option<Vec<u8>> {
293        std::fs::read(self.cache_path(name)?).ok()
294    }
295
296    /// `C;S;name;base64`: store a file in the cache.
297    fn store(&self, fields: &[&str]) {
298        let (Some(name), Some(data)) = (fields.first(), fields.get(1)) else { return };
299        let (Some(path), Some(bytes)) = (self.cache_path(name), decode_b64(data)) else {
300            return log::warn!("not caching {name:?}: a bad name or data");
301        };
302        let dir = path.parent().unwrap_or(Path::new("."));
303        let used: u64 = std::fs::read_dir(dir)
304            .map(|d| d.filter_map(|e| e.ok()?.metadata().ok()).map(|m| m.len()).sum())
305            .unwrap_or(0);
306        if used + bytes.len() as u64 > MAX_CACHE {
307            return log::warn!("not caching {name}: the cache for this BBS is full");
308        }
309        if let Err(e) = std::fs::create_dir_all(dir).and_then(|_| std::fs::write(&path, &bytes)) {
310            log::warn!("caching {}: {e}", path.display());
311        }
312    }
313
314    /// `C;L[;pattern]`: the cached files and their MD5 sums.
315    fn list(&self, pattern: &str, replies: &mut Vec<u8>) {
316        let mut out = b"\x1b_SyncTERM:C;L\n".to_vec();
317        if let Some(dir) = &self.cache_dir {
318            let mut names: Vec<(String, PathBuf)> = std::fs::read_dir(dir)
319                .into_iter()
320                .flatten()
321                .filter_map(|e| e.ok())
322                .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
323                .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
324                .filter(|(n, _)| glob(pattern.as_bytes(), n.as_bytes()))
325                .collect();
326            names.sort();
327            for (name, path) in names {
328                if let Ok(bytes) = std::fs::read(&path) {
329                    out.extend_from_slice(format!("{name}\t{:x}\n", md5::compute(&bytes)).as_bytes());
330                }
331            }
332        }
333        out.extend_from_slice(b"\x1b\\");
334        replies.extend_from_slice(&out);
335    }
336
337    /// Draw `img` as DrawPPM's options say.
338    fn draw(&self, img: &Image, args: &Args, screen: &mut Screen) {
339        let num = |n: &str, d: i64| args.num(n).unwrap_or(d);
340        let sx = num("SX", 0).clamp(0, img.width as i64) as usize;
341        let sy = num("SY", 0).clamp(0, img.height as i64) as usize;
342        let sw = num("SW", (img.width - sx) as i64).clamp(0, (img.width - sx) as i64) as usize;
343        let sh = num("SH", (img.height - sy) as i64).clamp(0, (img.height - sy) as i64) as usize;
344        let (zx, zy) = (num("ZX", 1).clamp(1, 64) as usize, num("ZY", 1).clamp(1, 64) as usize);
345        let (fx, fy) = (args.flag("FX"), args.flag("FY"));
346        let (dx, dy) = (num("DX", 0) as isize, num("DY", 0) as isize);
347        let (mx, my) = (num("MX", 0).max(0) as usize, num("MY", 0).max(0) as usize);
348        let mask = if args.flag("MBUF") {
349            self.mask.clone()
350        } else if let Some(file) = args.text("MFILE") {
351            self.read_cached(file).and_then(|b| decode_pbm(&b))
352        } else if let Some(bits) = args.text("MASK") {
353            let (w, h) = (num("MW", 0).max(0) as usize, num("MH", 0).max(0) as usize);
354            decode_b64(bits).filter(|_| w > 0 && h > 0 && w <= MAX_SIDE && h <= MAX_SIDE).map(|b| Mask {
355                width: w,
356                height: h,
357                bits: (0..w * h).map(|i| b.get(i / 8).is_some_and(|byte| byte & (0x80 >> (i % 8)) != 0)).collect(),
358            })
359        } else {
360            None
361        };
362        if sw == 0 || sh == 0 {
363            return;
364        }
365        screen.draw_pixels((dx, dy, sw * zx, sh * zy), |x, y| {
366            let (i, j) = ((x - dx) as usize / zx, (y - dy) as usize / zy);
367            let (i, j) = (if fx { sw - 1 - i } else { i }, if fy { sh - 1 - j } else { j });
368            if mask.as_ref().is_some_and(|m| !m.get(mx + i, my + j)) {
369                return 0;
370            }
371            img.pixels[(sy + j) * img.width + sx + i]
372        });
373    }
374}
375
376/// `P;Copy`: the screen's pixels in a rectangle.
377fn copy(screen: &Screen, args: &Args) -> Image {
378    let (w, h) = screen.size_px();
379    let x = args.num("X").unwrap_or(0).clamp(0, w as i64) as usize;
380    let y = args.num("Y").unwrap_or(0).clamp(0, h as i64) as usize;
381    let cw = args.num("W").unwrap_or((w - x) as i64).clamp(0, (w - x) as i64) as usize;
382    let ch = args.num("H").unwrap_or((h - y) as i64).clamp(0, (h - y) as i64) as usize;
383    let mut pixels = Vec::with_capacity(cw * ch);
384    for yy in y..y + ch {
385        for xx in x..x + cw {
386            pixels.push(solid(screen.pixel_at(xx, yy)));
387        }
388    }
389    Image { width: cw, height: ch, pixels }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn apc(st: &mut SyncTerm, screen: &mut Screen, s: &str) -> Vec<u8> {
397        let mut replies = Vec::new();
398        assert!(st.apc(s.as_bytes(), screen, &mut replies));
399        replies
400    }
401
402    /// A 2x2 PPM: red, green / blue, white.
403    const PPM: &[u8] = b"P6\n# a comment\n2 2\n255\n\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff";
404
405    #[test]
406    fn pictures_decode() {
407        let img = decode_ppm(PPM).unwrap();
408        assert_eq!((img.width, img.height), (2, 2));
409        assert_eq!(img.pixels, [solid(0xFF0000), solid(0x00FF00), solid(0x0000FF), solid(0xFFFFFF)]);
410        let plain = decode_ppm(b"P3 2 1 15  15 0 0  0 0 15").unwrap();
411        assert_eq!(plain.pixels, [solid(0xFF0000), solid(0x0000FF)]);
412        let m = decode_pbm(b"P4\n10 2\n\xc0\x40\x00\x00").unwrap();
413        assert_eq!((m.width, m.height), (10, 2));
414        assert!(m.get(0, 0) && m.get(1, 0) && !m.get(2, 0) && m.get(9, 0) && !m.get(0, 1));
415        let m = decode_pbm(b"P1 3 1 1 0 1").unwrap();
416        assert_eq!(m.bits, [true, false, true]);
417        assert!(decode_ppm(b"P6 99999 1 255 x").is_none());
418    }
419
420    #[test]
421    fn cache_draw_zoom_flip_and_mask() {
422        let dir = std::env::temp_dir().join(format!("badterm-cache-test-{}", std::process::id()));
423        let _ = std::fs::remove_dir_all(&dir);
424        let mut st = SyncTerm { cache_dir: Some(dir.clone()), ..Default::default() };
425        let mut s = Screen::new(80, 25, 0);
426        let b64 = BASE64.encode(PPM);
427        apc(&mut st, &mut s, &format!("SyncTERM:C;S;pic.ppm;{b64}"));
428        // Bad names aren't stored.
429        apc(&mut st, &mut s, &format!("SyncTERM:C;S;../evil;{b64}"));
430        let list = String::from_utf8(apc(&mut st, &mut s, "SyncTERM:C;L;*.ppm")).unwrap();
431        assert_eq!(list, format!("\x1b_SyncTERM:C;L\npic.ppm\t{:x}\n\x1b\\", md5::compute(PPM)));
432        assert!(!dir.parent().unwrap().join("evil").exists());
433
434        // Zoomed 2x at (10, 20), flipped across: green, red / white, blue.
435        apc(&mut st, &mut s, "SyncTERM:C;DrawPPM;DX=10;DY=20;ZX=2;ZY=2;FX;pic.ppm");
436        assert_eq!(s.pixel_at(10, 20), 0x00FF00);
437        assert_eq!(s.pixel_at(11, 21), 0x00FF00);
438        assert_eq!(s.pixel_at(12, 20), 0xFF0000);
439        assert_eq!(s.pixel_at(10, 22), 0xFFFFFF);
440        assert_eq!(s.pixel_at(14, 20), 0, "the background beyond");
441
442        // Inline, off the top left edge, through a mask: only (1,1) shows.
443        let mask = BASE64.encode(&[0b0001_0000]);
444        apc(&mut st, &mut s, &format!("SyncTERM:C;DrawPPMBlob;DX=-1;DY=-1;MW=2;MH=2;MASK={mask};{b64}"));
445        assert_eq!(s.pixel_at(0, 0), 0xFFFFFF);
446        assert_eq!(s.pixel_at(1, 0), 0);
447
448        // Copy a piece of the screen and paste it elsewhere.
449        apc(&mut st, &mut s, "SyncTERM:P;Copy;B=1;X=10;Y=20;W=4;H=4");
450        apc(&mut st, &mut s, "SyncTERM:P;Paste;B=1;DX=100;DY=100");
451        assert_eq!(s.pixel_at(100, 100), 0x00FF00);
452        assert_eq!(s.pixel_at(103, 103), 0x0000FF);
453
454        // Text written over a cell replaces its pixels.
455        s.move_to(1, 1);
456        s.put(b'x');
457        assert_eq!(s.pixel_at(10, 20), 0);
458
459        assert_eq!(apc(&mut st, &mut s, "SyncTERM:Q;JXL"), b"\x1b[=1;1-n");
460        let _ = std::fs::remove_dir_all(&dir);
461    }
462
463    #[test]
464    fn globbing() {
465        assert!(glob(b"*", b"a.ppm") && glob(b"*.ppm", b"a.ppm") && glob(b"a?ppm", b"a.ppm"));
466        assert!(!glob(b"*.jxl", b"a.ppm") && !glob(b"a", b"ab"));
467    }
468
469    /// A JPEG XL file: `BADTERM_JXL=pic.jxl cargo test -p badterm-core jxl -- --ignored`
470    #[test]
471    #[ignore]
472    fn jxl_decodes() {
473        let data = std::fs::read(std::env::var("BADTERM_JXL").expect("set BADTERM_JXL")).unwrap();
474        let img = decode_jxl(&data).expect("decodes");
475        eprintln!("{}x{}", img.width, img.height);
476        assert!(img.pixels.iter().any(|&p| p != solid(0)));
477    }
478}