diff --git a/cruciverb-core/src/export/puz.rs b/cruciverb-core/src/export/puz.rs new file mode 100644 index 0000000..8b41c3b --- /dev/null +++ b/cruciverb-core/src/export/puz.rs @@ -0,0 +1,282 @@ +use crate::grid::Cell; +use crate::puzzle::Puzzle; + +/// Serialize a puzzle to Across Lite .puz binary format. +/// Spec: https://code.google.com/archive/p/puz/wikis/FileFormat.wiki +pub fn to_puz(puzzle: &Puzzle, title: &str, author: &str) -> Vec { + let w = puzzle.width as u8; + let h = puzzle.height as u8; + let num_clues = (puzzle.clues_across.len() + puzzle.clues_down.len()) as u16; + + // build solution and player state strings + let mut solution = Vec::with_capacity(puzzle.width * puzzle.height); + let mut state = Vec::with_capacity(puzzle.width * puzzle.height); + for r in 0..puzzle.height { + for c in 0..puzzle.width { + match puzzle.grid.get(r, c) { + Cell::Black => { + solution.push(b'.'); + state.push(b'.'); + } + Cell::Letter(ch) => { + solution.push(ch as u8); + state.push(b'-'); + } + Cell::Empty => { + solution.push(b'A'); + state.push(b'-'); + } + } + } + } + + // build clue list in .puz order: sorted by clue number, across before down for same number + let mut clues: Vec<(u32, &str, bool)> = Vec::new(); + for c in &puzzle.clues_across { + clues.push((c.number, &c.text, true)); + } + for c in &puzzle.clues_down { + clues.push((c.number, &c.text, false)); + } + clues.sort_by_key(|(num, _, is_across)| (*num, !*is_across)); + + let clue_strings: Vec<&str> = clues.iter().map(|(_, text, _)| *text).collect(); + + // build the file + let mut buf = Vec::new(); + + // header checksum placeholder (2 bytes) - will fill in later + let cksum_pos = buf.len(); + buf.extend_from_slice(&[0u8; 2]); + + // file magic "ACROSS&DOWN\0" + buf.extend_from_slice(b"ACROSS&DOWN\0"); + + // overall checksum placeholder (2 bytes) + buf.extend_from_slice(&[0u8; 2]); + + // masked checksums (8 bytes) - computed later against the spec's "ICHEATED" mask + buf.extend_from_slice(&[0u8; 8]); + + // version string "1.3\0" + buf.extend_from_slice(b"1.3\0"); + + // reserved (2 bytes) + buf.extend_from_slice(&[0u8; 2]); + + // scrambled checksum (2 bytes, 0 = unscrambled) + buf.extend_from_slice(&[0u8; 2]); + + // reserved (12 bytes) + buf.extend_from_slice(&[0u8; 12]); + + // width, height + buf.push(w); + buf.push(h); + + // number of clues (little-endian u16) + buf.extend_from_slice(&num_clues.to_le_bytes()); + + // puzzle type (1 = normal, little-endian u16) + buf.extend_from_slice(&1u16.to_le_bytes()); + + // solution type (0 = normal, little-endian u16) + buf.extend_from_slice(&0u16.to_le_bytes()); + + // solution + buf.extend_from_slice(&solution); + + // player state + buf.extend_from_slice(&state); + + // strings section: title, author, copyright, clues, notes - all null-terminated + buf.extend_from_slice(title.as_bytes()); + buf.push(0); + + buf.extend_from_slice(author.as_bytes()); + buf.push(0); + + // copyright + buf.push(0); + + // clues (null-terminated each) + for clue in &clue_strings { + buf.extend_from_slice(clue.as_bytes()); + buf.push(0); + } + + // notes + buf.push(0); + + // now compute checksums (per the puz format spec: CIB, grid strings, and the + // four other checksums XOR-masked against "ICHEATED") + + // CIB: eight header bytes from width through the solution type (0x2C..0x34) + let c_cib = cksum_region(&buf[0x2C..0x34]); + let c_sol = cksum_region(&solution); + let c_grid = cksum_region(&state); + + // partial string checksum: title/author/copyright/notes include their NUL, + // clue texts do not + let mut c_part = 0u16; + for s in [title, author] { + if !s.is_empty() { + c_part = cksum_region_with_init(s.as_bytes(), c_part); + c_part = cksum_region_with_init(&[0u8], c_part); + } + } + for clue in &clue_strings { + c_part = cksum_region_with_init(clue.as_bytes(), c_part); + } + + // masked low/high checksum bytes at 0x10..0x17 + let magic = b"ICHEATED"; + let parts = [c_cib, c_sol, c_grid, c_part]; + let masked_pos = 0x10usize; + for i in 0..4usize { + buf[masked_pos + i] = magic[i] ^ (parts[i] & 0xFF) as u8; + buf[masked_pos + 4 + i] = magic[4 + i] ^ ((parts[i] & 0xFF00) >> 8) as u8; + } + + // overall checksum: CIB, solution, state, strings, then the masked bytes + let mut cksum = c_cib; + cksum = cksum_region_with_init(&solution, cksum); + cksum = cksum_region_with_init(&state, cksum); + for s in [title, author] { + if !s.is_empty() { + cksum = cksum_region_with_init(s.as_bytes(), cksum); + cksum = cksum_region_with_init(&[0u8], cksum); + } + } + for clue in &clue_strings { + cksum = cksum_region_with_init(clue.as_bytes(), cksum); + } + cksum = cksum_region_with_init(&buf[0x10..0x18], cksum); + + // write checksums + buf[cksum_pos] = (cksum & 0xFF) as u8; + buf[cksum_pos + 1] = ((cksum >> 8) & 0xFF) as u8; + buf[0x0E] = (c_cib & 0xFF) as u8; + buf[0x0F] = ((c_cib >> 8) & 0xFF) as u8; + + buf +} + +fn cksum_region(data: &[u8]) -> u16 { + cksum_region_with_init(data, 0) +} + +fn cksum_region_with_init(data: &[u8], init: u16) -> u16 { + let mut cksum = init; + for &b in data { + if cksum & 1 != 0 { + cksum = (cksum >> 1) + 0x8000; + } else { + cksum >>= 1; + } + cksum = cksum.wrapping_add(b as u16); + } + cksum +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::SymmetryType; + use crate::grid::slot::Direction; + use crate::grid::{Cell, Grid}; + use crate::puzzle::{Clue, PlacedWord, Puzzle}; + + fn mini_puzzle() -> Puzzle { + let mut grid = Grid::new(2, 2); + grid.set(0, 0, Cell::Letter('A')); + grid.set(0, 1, Cell::Letter('B')); + grid.set(1, 0, Cell::Letter('C')); + grid.set(1, 1, Cell::Letter('D')); + let words = vec![ + PlacedWord { word: "AB".into(), row: 0, col: 0, direction: Direction::Across, clue_number: 1 }, + PlacedWord { word: "CD".into(), row: 1, col: 0, direction: Direction::Across, clue_number: 3 }, + PlacedWord { word: "AC".into(), row: 0, col: 0, direction: Direction::Down, clue_number: 1 }, + PlacedWord { word: "BD".into(), row: 0, col: 1, direction: Direction::Down, clue_number: 2 }, + ]; + let clue = |number: u32, direction: Direction, text: &str, answer: &str| Clue { + number, + direction, + text: text.into(), + answer: answer.into(), + definition: None, + wordplay_type: None, + }; + Puzzle { + grid, + words, + clues_across: vec![ + clue(1, Direction::Across, "First row", "AB"), + clue(3, Direction::Across, "Second row", "CD"), + ], + clues_down: vec![ + clue(1, Direction::Down, "First col", "AC"), + clue(2, Direction::Down, "Second col", "BD"), + ], + width: 2, + height: 2, + symmetry: SymmetryType::None, + difficulty_score: 50, + generation_time_ms: 0, + theme: None, + theme_entries: vec![], + is_cryptic: false, + } + } + + #[test] + fn checksums_follow_the_format_spec() { + let title = "TP"; + let author = "AU"; + let buf = to_puz(&mini_puzzle(), title, author); + + let w = buf[0x2C] as usize; + let h = buf[0x2D] as usize; + let sol = &buf[0x34..0x34 + w * h]; + let state = &buf[0x34 + w * h..0x34 + 2 * w * h]; + + let c_cib = cksum_region(&buf[0x2C..0x34]); + let c_sol = cksum_region(sol); + let c_grid = cksum_region(state); + + // clue texts in .puz order: sorted by number, across before down + let clue_texts = ["First row", "First col", "Second col", "Second row"]; + let mut c_part = 0u16; + for s in [title, author] { + c_part = cksum_region_with_init(s.as_bytes(), c_part); + c_part = cksum_region_with_init(&[0u8], c_part); + } + for c in clue_texts { + c_part = cksum_region_with_init(c.as_bytes(), c_part); + } + + // masked region, XORed against "ICHEATED" + let magic = b"ICHEATED"; + let parts = [c_cib, c_sol, c_grid, c_part]; + for i in 0..4usize { + assert_eq!(buf[0x10 + i], magic[i] ^ (parts[i] & 0xFF) as u8, "masked low {}", i); + assert_eq!(buf[0x14 + i], magic[4 + i] ^ ((parts[i] & 0xFF00) >> 8) as u8, "masked high {}", i); + } + + // overall checksum + let mut expect = c_cib; + expect = cksum_region_with_init(sol, expect); + expect = cksum_region_with_init(state, expect); + for s in [title, author] { + expect = cksum_region_with_init(s.as_bytes(), expect); + expect = cksum_region_with_init(&[0u8], expect); + } + for c in clue_texts { + expect = cksum_region_with_init(c.as_bytes(), expect); + } + expect = cksum_region_with_init(&buf[0x10..0x18], expect); + + assert_eq!(u16::from_le_bytes([buf[0], buf[1]]), expect, "overall"); + assert_eq!(u16::from_le_bytes([buf[0x0E], buf[0x0F]]), c_cib, "cib"); + } +}