puz export with checksums

This commit is contained in:
2025-06-25 14:16:33 +03:00
parent 10125139a9
commit 3245976a8c
+282
View File
@@ -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<u8> {
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");
}
}