commit 8da41f5fe424084b823e017f79b34315b6d44a75 Author: Chris Mounce Date: Tue Jan 20 17:31:44 2026 -0800 Adapt ZZT world/board parsing from Marzipan diff --git a/src/zzt/encoding.rs b/src/zzt/encoding.rs new file mode 100644 index 0000000..6060631 --- /dev/null +++ b/src/zzt/encoding.rs @@ -0,0 +1,110 @@ +use codepage_437::CP437_WINGDINGS; + +use super::error::ParseError; + +/// Serialize a multi-line code string for an object or a scroll. +/// Uses ZZT's convention of CR-terminated lines. +pub fn encode_multiline(input: &str) -> Result, ParseError> { + input + .chars() + .map(|c| { + if c == '\n' { + Ok(b'\r') + } else { + CP437_WINGDINGS + .encode(c) + .ok_or_else(|| ParseError::EncodingError(c)) + } + }) + .collect() +} + +/// Deserialize a multi-line code string. +pub fn decode_multiline(input: &[u8]) -> String { + input + .iter() + .map(|&x| { + if x == b'\r' { + '\n' + } else { + CP437_WINGDINGS.decode(x) + } + }) + .collect() +} + +/// Serialize a single-line string for a board title. +/// Newlines cannot be encoded because ZZT interprets them as dingbats (♪). +pub fn encode_oneline(input: &str) -> Result, ParseError> { + input + .chars() + .map(|c| { + CP437_WINGDINGS + .encode(c) + .ok_or_else(|| ParseError::EncodingError(c)) + }) + .collect() +} + +/// Deserialize a board title. +pub fn decode_oneline(input: &[u8]) -> String { + input.iter().map(|&x| CP437_WINGDINGS.decode(x)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + + fn serialize_code(input: &str) -> Vec { + encode_multiline(input).expect("Error in test") + } + + fn serialize_title(input: &str) -> Vec { + encode_oneline(input).expect("Error in test") + } + + #[test] + fn roundtrip_multiline() { + let bytes: Vec = (0..=255).collect(); + assert_eq!(bytes, serialize_code(&decode_multiline(&bytes))) + } + + #[test] + fn roundtrip_oneline() { + let bytes: Vec = (0..=255).collect(); + assert_eq!(bytes, serialize_title(&decode_oneline(&bytes))) + } + + #[test] + fn newlines_to_cr() { + assert_debug_snapshot!(serialize_code("ABC\nDEF"), @r###" + [ + 65, + 66, + 67, + 13, + 68, + 69, + 70, + ] + "###) + } + + #[test] + fn byte_13_to_dingbat() { + // Code is allowed to have newlines + let bytes = serialize_code("ABC\nDEF"); + assert!(bytes.contains(&13)); + + // But in a board title, that same byte is a dingbat + assert_debug_snapshot!(decode_oneline(&bytes), @r#""ABC♪DEF""#); + } + + #[test] + fn wingdings() { + assert_debug_snapshot!(decode_multiline(&(0..32).collect::>()), @r###""\0☺☻♥♦♣♠•◘○◙♂♀\n♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼""###); + assert_debug_snapshot!(decode_multiline(&(112..144).collect::>()), @r###""pqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅ""###); + assert_debug_snapshot!(decode_multiline(&(224..=255).collect::>()), @r###""αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}""###); + } +} diff --git a/src/zzt/error.rs b/src/zzt/error.rs new file mode 100644 index 0000000..a491bbd --- /dev/null +++ b/src/zzt/error.rs @@ -0,0 +1,45 @@ +use nom::error::{ErrorKind, ParseError as NomParseError}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("invalid ZZT file magic")] + InvalidMagic, + + #[error("invalid tile count: {0}")] + InvalidTileCount(usize), + + #[error("negative stat count")] + NegativeStatCount, + + #[error("string too long")] + StringTooLong { max: u8 }, + + #[error("cannot encode character: {0}")] + EncodingError(char), + + #[error("board data too large")] + BoardTooLarge, + + #[error("parse error: {0}")] + ParseError(String), +} + +impl NomParseError for ParseError { + fn from_error_kind(_input: I, kind: ErrorKind) -> Self { + Self::ParseError(kind.description().to_string()) + } + + fn append(_input: I, kind: ErrorKind, other: Self) -> Self { + Self::ParseError(format!("{}: {:?}", other, kind)) + } +} + +impl From> for ParseError { + fn from(value: nom::Err) -> Self { + match value { + nom::Err::Error(e) | nom::Err::Failure(e) => e, + nom::Err::Incomplete(needed) => Self::ParseError(format!("incomplete: {:?}", needed)), + } + } +} diff --git a/src/zzt/mod.rs b/src/zzt/mod.rs new file mode 100644 index 0000000..84952b2 --- /dev/null +++ b/src/zzt/mod.rs @@ -0,0 +1,7 @@ +mod encoding; +mod error; +mod parse; + +pub use encoding::{decode_multiline, decode_oneline, encode_multiline, encode_oneline}; +pub use error::ParseError; +pub use parse::{Board, Program, Stat, Tile, World}; diff --git a/src/zzt/parse.rs b/src/zzt/parse.rs new file mode 100644 index 0000000..8b8262b --- /dev/null +++ b/src/zzt/parse.rs @@ -0,0 +1,418 @@ +use nom::{ + IResult, Parser, + bytes::complete::{tag, take}, + multi::count, + number::complete::{le_i16, le_u8, le_u16}, +}; + +use super::encoding::{decode_multiline, decode_oneline, encode_multiline, encode_oneline}; +use super::error::ParseError; + +/// A single tile on a ZZT board. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tile { + pub element: u8, + pub color: u8, +} + +/// A stat's ZZT-OOP program. +/// +/// In ZZT, stats can either have their own code or bind to another stat's code. +/// This enum prevents invalid states where both are set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Program { + /// The stat owns its own code. + Own(String), + /// The stat is bound to another stat (index into the stats list). + Bound(u16), +} + +impl Default for Program { + fn default() -> Self { + Program::Own(String::new()) + } +} + +/// A status element on a ZZT board. +#[derive(Debug, Clone)] +pub struct Stat { + pub x: u8, + pub y: u8, + pub x_step: i16, + pub y_step: i16, + pub cycle: i16, + pub p1: u8, + pub p2: u8, + pub p3: u8, + pub follower: i16, + pub leader: i16, + pub under: Tile, + pub instruction_pointer: i16, + pub program: Program, +} + +/// A ZZT board. +#[derive(Debug, Clone)] +pub struct Board { + pub name: String, + pub tiles: Vec, + pub max_shots: u8, + pub is_dark: bool, + pub exit_north: u8, + pub exit_south: u8, + pub exit_west: u8, + pub exit_east: u8, + pub restart_on_zap: bool, + pub message: String, + pub enter_x: u8, + pub enter_y: u8, + pub time_limit: i16, + pub stats: Vec, +} + +/// A ZZT world file. +#[derive(Debug, Default)] +pub struct World { + pub ammo: i16, + pub gems: i16, + pub keys: [bool; 7], + pub health: i16, + pub starting_board: i16, + pub torches: i16, + pub torch_cycles: i16, + pub energizer_cycles: i16, + pub score: i16, + pub name: String, + pub flags: [String; 10], + pub time: i16, + pub time_ticks: i16, + pub locked: bool, + pub boards: Vec, +} + +// Parsing helpers + +fn bool_u8(input: &[u8]) -> IResult<&[u8], bool, ParseError> { + let (input, byte) = le_u8(input)?; + Ok((input, byte != 0)) +} + +fn pstring(cap: u8) -> impl Fn(&[u8]) -> IResult<&[u8], String, ParseError> { + move |input: &[u8]| -> IResult<&[u8], String, ParseError> { + let (input, len) = le_u8(input)?; + let actual_len = len.min(cap); + let (input, data) = take(actual_len)(input)?; + let (input, _) = take(cap - actual_len)(input)?; + Ok((input, decode_oneline(data))) + } +} + +fn board_slice(bytes: &[u8]) -> IResult<&[u8], &[u8], ParseError> { + let (_, size) = le_u16.parse(bytes)?; + take(size as usize + 2).parse(bytes) +} + +// Serialization helpers + +trait SerializationHelpers { + fn push_bool(&mut self, value: bool); + fn push_i16(&mut self, value: i16); + fn push_string(&mut self, cap: u8, value: &str) -> Result<(), ParseError>; + fn push_padding(&mut self, size: usize); +} + +impl SerializationHelpers for Vec { + fn push_bool(&mut self, value: bool) { + self.push(if value { 1 } else { 0 }); + } + + fn push_i16(&mut self, value: i16) { + self.extend(value.to_le_bytes()); + } + + fn push_string(&mut self, cap: u8, value: &str) -> Result<(), ParseError> { + let bytes = encode_oneline(value)?; + if bytes.len() > cap as usize { + return Err(ParseError::StringTooLong { max: cap }); + } + self.push(bytes.len() as u8); + self.extend_from_slice(&bytes); + self.push_padding(cap as usize - bytes.len()); + Ok(()) + } + + fn push_padding(&mut self, size: usize) { + self.resize(self.len() + size, 0); + } +} + +impl World { + /// Parse a ZZT world from bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (input, _) = tag(&[0xff, 0xff][..]) + .parse(bytes) + .map_err(|_: nom::Err| ParseError::InvalidMagic)?; + let (input, num_boards) = le_i16.parse(input)?; + let (input, (ammo, gems, keys)) = (le_i16, le_i16, count(bool_u8, 7)).parse(input)?; + let (input, (health, starting_board, torches, torch_cycles, energizer_cycles)) = + (le_i16, le_i16, le_i16, le_i16, le_i16).parse(input)?; + let (input, (_, score, name)) = (take(2usize), le_i16, pstring(20)).parse(input)?; + let (input, flags) = count(pstring(20), 10).parse(input)?; + let (_input, (time, time_ticks, locked)) = (le_i16, le_i16, bool_u8).parse(input)?; + + // Rest of header is padding; fast-forward starting from original input + let (input, _) = take(512usize).parse(bytes)?; + + // Load boards + let num_boards = num_boards as usize + 1; + let (_input, chunks) = count(board_slice, num_boards).parse(input)?; + let boards: Result, ParseError> = chunks + .iter() + .map(|bytes: &&[u8]| Board::from_bytes(bytes)) + .collect(); + let boards = boards?; + + Ok(World { + ammo, + gems, + keys: keys.try_into().unwrap(), + health, + starting_board, + torches, + torch_cycles, + energizer_cycles, + score, + name, + flags: flags.try_into().unwrap(), + time, + time_ticks, + locked, + boards, + }) + } + + /// Serialize this world to bytes. + pub fn to_bytes(&self) -> Result, ParseError> { + let mut result = Vec::with_capacity(512); + result.push_i16(-1); // file magic: ZZT world + result.push_i16(self.boards.len() as i16 - 1); + result.push_i16(self.ammo); + result.push_i16(self.gems); + for key in self.keys { + result.push_bool(key); + } + result.push_i16(self.health); + result.push_i16(self.starting_board); + result.push_i16(self.torches); + result.push_i16(self.torch_cycles); + result.push_i16(self.energizer_cycles); + result.push_padding(2); + result.push_i16(self.score); + result.push_string(20, &self.name)?; + for flag in &self.flags { + result.push_string(20, flag)?; + } + result.push_i16(self.time); + result.push_i16(self.time_ticks); + result.push_bool(self.locked); + result.push_padding(512 - result.len()); + + for board in &self.boards { + result.extend_from_slice(&board.to_bytes()?); + } + Ok(result) + } +} + +impl Board { + /// Parse a board from bytes (including the 2-byte size header). + pub fn from_bytes(bytes: &[u8]) -> Result { + // Ignore length bytes + let (input, _) = le_u16.parse(bytes)?; + + // Read board name + let (input, name) = pstring(50)(input)?; + + // Read terrain + const NUM_TILES: usize = 60 * 25; + let mut input = input; + let mut tiles = Vec::with_capacity(NUM_TILES); + while tiles.len() < NUM_TILES { + let (next_input, (count, element, color)) = (le_u8, le_u8, le_u8).parse(input)?; + input = next_input; + let count: usize = if count == 0 { 256 } else { count.into() }; + for _ in 0..count { + tiles.push(Tile { element, color }); + if tiles.len() > NUM_TILES { + return Err(ParseError::InvalidTileCount(tiles.len())); + } + } + } + + // Read board info + let (input, (max_shots, is_dark)) = (le_u8, bool_u8).parse(input)?; + let (input, (exit_north, exit_south, exit_west, exit_east)) = + (le_u8, le_u8, le_u8, le_u8).parse(input)?; + let (input, (restart_on_zap, message)) = (bool_u8, pstring(58)).parse(input)?; + let (input, (enter_x, enter_y, time_limit)) = (le_u8, le_u8, le_i16).parse(input)?; + let (input, _) = take(16usize)(input)?; + + // Read stats + let (input, num_stats) = le_i16(input)?; + let num_stats = num_stats + 1; + if num_stats < 0 { + return Err(ParseError::NegativeStatCount); + } + let (_input, stats) = count(Stat::parse, num_stats as usize).parse(input)?; + + Ok(Board { + name, + tiles, + max_shots, + is_dark, + exit_north, + exit_south, + exit_east, + exit_west, + restart_on_zap, + message, + enter_x, + enter_y, + time_limit, + stats, + }) + } + + /// Parse a standalone .brd file (same format, no world header). + pub fn from_brd_bytes(bytes: &[u8]) -> Result { + Board::from_bytes(bytes) + } + + /// Serialize this board to bytes. + pub fn to_bytes(&self) -> Result, ParseError> { + let mut result = vec![]; + result.push_padding(2); // reserve space for board size + result.push_string(50, &self.name)?; + + // Encode terrain + if self.tiles.len() != 1500 { + return Err(ParseError::InvalidTileCount(self.tiles.len())); + } + let mut iter = self.tiles.iter().peekable(); + while let Some(tile) = iter.next() { + let mut count: u8 = 1; + while count < 255 && iter.peek().map_or(false, |&next_tile| next_tile == tile) { + count += 1; + iter.next(); + } + result.push(count); + result.push(tile.element); + result.push(tile.color); + } + + // Board info + result.push(self.max_shots); + result.push_bool(self.is_dark); + result.push(self.exit_north); + result.push(self.exit_south); + result.push(self.exit_west); + result.push(self.exit_east); + result.push_bool(self.restart_on_zap); + result.push_string(58, &self.message)?; + result.push(self.enter_x); + result.push(self.enter_y); + result.push_i16(self.time_limit); + result.push_padding(16); + + // Stats + let num_stats: i16 = (self.stats.len() as i16) - 1; + result.push_i16(num_stats); + for stat in &self.stats { + result.extend_from_slice(&stat.to_bytes()?); + } + + // Fix up board size + let size: u16 = (result.len() - 2) + .try_into() + .map_err(|_| ParseError::BoardTooLarge)?; + result.splice(0..2, size.to_le_bytes()); + + Ok(result) + } +} + +impl Stat { + fn parse(input: &[u8]) -> IResult<&[u8], Self, ParseError> { + let (input, (x, y, x_step, y_step)) = (le_u8, le_u8, le_i16, le_i16).parse(input)?; + let (input, (cycle, p1, p2, p3)) = (le_i16, le_u8, le_u8, le_u8).parse(input)?; + let (input, (follower, leader)) = (le_i16, le_i16).parse(input)?; + let (input, (under_element, under_color)) = (le_u8, le_u8).parse(input)?; + let (input, _) = take(4usize)(input)?; // unused pointer + let (input, (instruction_pointer, length)) = (le_i16, le_i16).parse(input)?; + let (input, _) = take(8usize)(input)?; // padding + + let (input, program) = if length < 0 { + // Negative length means bound to another stat + (input, Program::Bound((-length) as u16)) + } else { + // Positive length means own code + let (input, code_bytes) = take(length as usize)(input)?; + (input, Program::Own(decode_multiline(code_bytes))) + }; + + Ok(( + input, + Stat { + x, + y, + x_step, + y_step, + follower, + leader, + cycle, + p1, + p2, + p3, + under: Tile { + element: under_element, + color: under_color, + }, + instruction_pointer, + program, + }, + )) + } + + fn to_bytes(&self) -> Result, ParseError> { + let mut result = vec![]; + result.push(self.x); + result.push(self.y); + result.push_i16(self.x_step); + result.push_i16(self.y_step); + result.push_i16(self.cycle); + result.push(self.p1); + result.push(self.p2); + result.push(self.p3); + result.push_i16(self.follower); + result.push_i16(self.leader); + result.push(self.under.element); + result.push(self.under.color); + result.push_padding(4); // unused pointer + result.push_i16(self.instruction_pointer); + + match &self.program { + Program::Bound(index) => { + result.push_i16(-(*index as i16)); + result.push_padding(8); + } + Program::Own(code) => { + let code_bytes = encode_multiline(code)?; + result.push_i16(code_bytes.len() as i16); + result.push_padding(8); + result.extend_from_slice(&code_bytes); + } + } + + Ok(result) + } +}