puz export with checksums
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user