Adapt ZZT world/board parsing from Marzipan

This commit is contained in:
2026-01-20 17:31:44 -08:00
commit 8da41f5fe4
4 changed files with 580 additions and 0 deletions
+110
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<u8> {
encode_multiline(input).expect("Error in test")
}
fn serialize_title(input: &str) -> Vec<u8> {
encode_oneline(input).expect("Error in test")
}
#[test]
fn roundtrip_multiline() {
let bytes: Vec<u8> = (0..=255).collect();
assert_eq!(bytes, serialize_code(&decode_multiline(&bytes)))
}
#[test]
fn roundtrip_oneline() {
let bytes: Vec<u8> = (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::<Vec<_>>()), @r###""\0☺☻♥♦♣♠•◘○◙♂♀\n♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼""###);
assert_debug_snapshot!(decode_multiline(&(112..144).collect::<Vec<_>>()), @r###""pqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅ""###);
assert_debug_snapshot!(decode_multiline(&(224..=255).collect::<Vec<_>>()), @r###""αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}""###);
}
}
+45
View File
@@ -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<I> NomParseError<I> 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<nom::Err<ParseError>> for ParseError {
fn from(value: nom::Err<ParseError>) -> Self {
match value {
nom::Err::Error(e) | nom::Err::Failure(e) => e,
nom::Err::Incomplete(needed) => Self::ParseError(format!("incomplete: {:?}", needed)),
}
}
}
+7
View File
@@ -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};
+418
View File
@@ -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<Tile>,
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<Stat>,
}
/// 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<Board>,
}
// 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<u8> {
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<World, ParseError> {
let (input, _) = tag(&[0xff, 0xff][..])
.parse(bytes)
.map_err(|_: nom::Err<ParseError>| 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<Vec<Board>, 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<Vec<u8>, 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<Board, ParseError> {
// 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, ParseError> {
Board::from_bytes(bytes)
}
/// Serialize this board to bytes.
pub fn to_bytes(&self) -> Result<Vec<u8>, 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<Vec<u8>, 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)
}
}