crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 23499bca33023cbee34d7e0dae0ae335fe6a8026
parent 27b2a410ead5da69415c847eb0d362bc9aa08c4e
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 19:46:52 +0900

Fix Across Lite conversion crash, corruption and off-by-one error

Three related fixes to Across Lite (.puz) rebus handling:

- PUZToXDConverter keyed its grid placeholders by cell index rather than
  by distinct rebus value, so the 13th key was `=` (breaking the Rebus:
  grammar), the 16th `@` (reparsed as a circled cell), and the 207th
  rebus-covered cell trapped on UInt8 overflow. Dedupe by value into
  [String: Character], mirroring NYTToXDConverter, and draw from a
  shared, reserved-char-avoiding placeholder set hoisted onto
  XD.rebusPlaceholders (the writer-side counterpart to
  XD.parseRebusHeader), with a bounds check that fails closed instead of
  doing UInt8 arithmetic.

- parseRebus read the GRBS grid byte directly as the RTBL key, but .puz
  stores GRBS = RTBL_key + 1 (0 = no rebus). On a real rebus file a
  single-value rebus silently dropped every rebus cell and a multi-value
  rebus mis-mapped each cell to the next value and dropped the last -
  silent corruption, no crash, which is why it shipped. Confirmed
  against the puz FileFormat wiki and puzpy. Fix: table[key - 1].

- adds GRBS/RTBL round-trip coverage plus a single-value decode
  regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Models/XD.swift | 22++++++++++++++++++++++
MCrossmate/Services/NYTToXDConverter.swift | 26++------------------------
MCrossmate/Services/PUZToXDConverter.swift | 33++++++++++++++++++++++++++-------
MTests/Unit/PUZToXDConverterTests.swift | 152++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 194 insertions(+), 39 deletions(-)

diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -20,6 +20,28 @@ struct XD: Sendable { /// regex, so a single oversized clue under the source cap is still costly. static let maxClueTextLength = 1_024 + /// Single-character grid placeholders for multi-letter (rebus) fills, used + /// by every converter that *writes* `.xd`. The grid is one character per + /// cell, so a cell whose fill is longer than one letter shows one of these + /// and is expanded via the `Rebus:` header (the `1` in `1=LW`). The spec + /// allows "digits, most symbols, and printable unicode characters" here; we + /// take digits first (the conventional, readable encoding) then the ASCII + /// symbols, where "most" excludes the characters the grid/header parser + /// already reserves — letters (grid fill), `#`/`_`/`.` (block/empty), + /// `@`/`*` (`Specials:` markers), and `=`/space (`Rebus:` `key=value` + /// syntax, split on whitespace by `parseRebusHeader`). We stop at ASCII + /// rather than walking into unicode; real puzzles use a handful of distinct + /// fills, nowhere near the ceiling. (Walking ASCII naïvely from `'1'` + /// overflows into `=` by the 13th key, producing an unparseable `==VALUE` + /// entry and a rejected grid char.) + static let rebusPlaceholders: [Character] = { + let reserved: Set<Character> = ["#", "_", ".", "@", "*", "=", " "] + let symbols = (UInt8(33)...UInt8(126)) + .map { Character(UnicodeScalar($0)) } + .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) } + return Array("123456789") + symbols + }() + let title: String? let publisher: String? let author: String? diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -7,28 +7,6 @@ enum NYTToXDConverter { var errorDescription: String? { message } } - /// Single-character grid placeholders for multi-letter (rebus) fills. The - /// `.xd` grid is one character per cell, so a cell whose fill is longer than - /// one letter shows one of these in the grid and is expanded via the - /// `Rebus:` header (the `1` in `1=LW`). These are *not* NYT data — they're - /// our `.xd` serialization. The `.xd` spec allows "digits, most symbols, - /// and printable unicode characters (if needed)" here; we take digits first - /// (the conventional, readable encoding) then the symbols, where "most" - /// means every printable ASCII symbol *except* the ones the grid/header - /// parser already reserves — letters (grid fill), `#`/`_`/`.` (block/empty), - /// `@`/`*` (special markers), and `=`/space (`Rebus:` `key=value` syntax). - /// We stop at ASCII rather than walking into unicode and throw past the - /// ceiling; real puzzles use a handful of distinct fills, nowhere near it. - /// (Walking ASCII naïvely from `'1'` overflows into `=` by the 13th key, - /// which produced an unparseable `==VALUE` entry and a rejected grid char.) - private static let rebusPlaceholders: [Character] = { - let reserved: Set<Character> = ["#", "_", ".", "@", "*", "=", " "] - let symbols = (UInt8(33)...UInt8(126)) - .map { Character(UnicodeScalar($0)) } - .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) } - return Array("123456789") + symbols - }() - /// Whether a cell fill must ride into the grid via a `Rebus:` placeholder /// rather than appear literally. The `.xd` grid is one character per cell and /// the grid parser only takes letters (plus block/special markers and @@ -152,12 +130,12 @@ enum NYTToXDConverter { for answer in answers { guard let answer, needsRebusEncoding(answer) else { continue } if rebusLookup[answer] == nil { - guard rebusLookup.count < rebusPlaceholders.count else { + guard rebusLookup.count < XD.rebusPlaceholders.count else { throw ConversionError( message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." ) } - let key = rebusPlaceholders[rebusLookup.count] + let key = XD.rebusPlaceholders[rebusLookup.count] rebusLookup[answer] = key rebusEntries.append((key: key, value: answer)) } diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -74,16 +74,32 @@ enum PUZToXDConverter { clueTexts: clueTexts ) + // Each distinct rebus fill claims one grid placeholder from + // `XD.rebusPlaceholders`; cells sharing a fill reuse the same key + // (mirrors `NYTToXDConverter`). Keying by cell index instead would burn + // a fresh character per cell and walk straight into the grid/header + // reserved set (`=`, `@`, ...) and, past ~200 cells, off the end of the + // alphabet — hence the bounds check rather than raw arithmetic. var rebusKeys: [Int: Character] = [:] var rebusEntries: [(Character, String)] = [] - var nextRebusKey: UInt8 = Character("1").asciiValue! + var rebusLookup: [String: Character] = [:] for index in 0..<cellCount where isOpen(solutionBytes[index]) { guard let value = rebus[index], !value.isEmpty else { continue } - guard rebusKeys[index] == nil else { continue } - let key = Character(UnicodeScalar(nextRebusKey)) + let normalized = value.uppercased() + let key: Character + if let existing = rebusLookup[normalized] { + key = existing + } else { + guard rebusLookup.count < XD.rebusPlaceholders.count else { + throw ConversionError( + message: "Across Lite puzzle has too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." + ) + } + key = XD.rebusPlaceholders[rebusLookup.count] + rebusLookup[normalized] = key + rebusEntries.append((key, normalized)) + } rebusKeys[index] = key - rebusEntries.append((key, value.uppercased())) - nextRebusKey += 1 } var metadata: [String] = [] @@ -321,8 +337,11 @@ enum PUZToXDConverter { var rebus: [Int: String] = [:] for offset in 0..<cellCount { let gridIndex = grid.index(grid.startIndex, offsetBy: offset) - let key = Int(grid[gridIndex]) - guard key > 0, let value = table[key] else { continue } + // The grid byte is 1-indexed: 0 means "no rebus" and `1 + n` refers + // to `RTBL` key `n` (see the .puz FileFormat reference). Subtract one + // to recover the table key. + let gridByte = Int(grid[gridIndex]) + guard gridByte > 0, let value = table[gridByte - 1] else { continue } rebus[offset] = value } return rebus diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -140,6 +140,113 @@ struct PUZToXDConverterTests { #expect(puzzle.cells[1][1].solution == "E") } + @Test("Rebus fills convert, dedupe by value, and round-trip") + func rebusFillsConvertAndDedupe() throws { + // Cells 0 and 8 share the fill CAT; cell 4 is DOG. The two CAT cells + // must collapse to a single Rebus header entry and reuse one grid + // placeholder — keying by cell index (the old bug) would mint three. + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Rebus", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ], + rebusGrid: [0: 0, 4: 1, 8: 0], + rebusTable: [0: "CAT", 1: "DOG"] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + #expect(header("Rebus", in: source) == "1=CAT 2=DOG") + #expect(source.contains("\n1BC\nD2F\nGH1\n")) + + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[0][0].solution == "CAT") + #expect(puzzle.cells[1][1].solution == "DOG") + #expect(puzzle.cells[2][2].solution == "CAT") + } + + @Test("A single rebus value at RTBL key 0 decodes rather than vanishing") + func singleRebusValueDecodes() throws { + // The common case: one rebus fill, `RTBL` key 0, so the `GRBS` byte is 1. + // Reading the grid byte directly as the table key (the off-by-one bug) + // looked up the absent key 1, silently dropping the rebus and leaving the + // cell's raw solution letter behind. + let data = try puzData( + width: 3, + height: 3, + solution: "ABCDEFGHI", + title: "Rebus", + author: "", + copyright: "", + clues: [ + "Across 1", + "Down 1", + "Down 2", + "Down 3", + "Across 4", + "Across 5" + ], + rebusGrid: [4: 0], + rebusTable: [0: "HEART"] + ) + + let source = try PUZToXDConverter.convert(puzData: data) + #expect(header("Rebus", in: source) == "1=HEART") + + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[1][1].solution == "HEART") + } + + @Test("Many distinct rebus fills never collide with reserved grid syntax") + func manyRebusFillsAvoidReservedKeys() throws { + // Keying placeholders by cell index used to reach '=' (the Rebus header + // delimiter) on the 13th rebus cell and '@' (the circle marker) on the + // 16th, and to trap on UInt8 overflow past ~207. Twenty distinct fills + // must all draw from the curated placeholder set and steer clear of + // every reserved grid/header character. + var grid: [Int: Int] = [:] + var table: [Int: String] = [:] + let values = (0..<20).map { "R\($0)X" } + for (offset, value) in values.enumerated() { + grid[offset] = offset + table[offset] = value + } + + let data = try puzData( + width: 5, + height: 5, + solution: String(repeating: "A", count: 25), + title: "Rebus", + author: "", + copyright: "", + clues: Array(repeating: "Clue", count: 10), + rebusGrid: grid, + rebusTable: table + ) + + let source = try PUZToXDConverter.convert(puzData: data) + let rebus = try #require(header("Rebus", in: source)) + for entry in rebus.split(separator: " ") { + let key = try #require(entry.first) + #expect(!["#", "_", ".", "@", "*", "="].contains(key)) + } + + // The whole puzzle now parses rather than throwing, with each distinct + // fill landing as its own multi-letter rebus solution. + let puzzle = Puzzle(xd: try XD.parse(source)) + #expect(puzzle.cells[0][0].solution == "R0X") + #expect(puzzle.cells[3][4].solution == "R19X") + } + private func puzData( width: UInt8, height: UInt8, @@ -149,7 +256,9 @@ struct PUZToXDConverterTests { copyright: String, clues: [String], notes: String = "", - circledCells: Set<Int> = [] + circledCells: Set<Int> = [], + rebusGrid: [Int: Int] = [:], + rebusTable: [Int: String] = [:] ) throws -> Data { let cellCount = Int(width) * Int(height) let solutionBytes = Array(solution.utf8) @@ -179,18 +288,45 @@ struct PUZToXDConverterTests { for index in circledCells where index >= 0 && index < cellCount { payload[index] = 0x80 } - data.append(contentsOf: "GEXT".utf8) - data.append(UInt8(cellCount & 0xFF)) - data.append(UInt8((cellCount >> 8) & 0xFF)) - data.append(0) - data.append(0) - data.append(contentsOf: payload) - data.append(0) + appendExtension("GEXT", payload: payload, into: &data) + } + // Rebus grid (`GRBS`) + table (`RTBL`) follow the real .puz convention: + // `rebusGrid` maps a cell to its 0-based `RTBL` key, and the grid byte is + // stored 1-indexed (`key + 1`), reserving 0 for "no rebus". + if !rebusGrid.isEmpty { + var payload = Data(repeating: 0, count: cellCount) + for (index, key) in rebusGrid where index >= 0 && index < cellCount { + payload[index] = UInt8(key + 1) + } + appendExtension("GRBS", payload: payload, into: &data) + + let table = rebusTable + .sorted { $0.key < $1.key } + .map { " \($0.key):\($0.value);" } + .joined() + appendExtension("RTBL", payload: try cp1252Data(table), into: &data) } return data } + private func appendExtension(_ code: String, payload: Data, into data: inout Data) { + data.append(contentsOf: code.utf8) + data.append(UInt8(payload.count & 0xFF)) + data.append(UInt8((payload.count >> 8) & 0xFF)) + data.append(0) + data.append(0) + data.append(payload) + data.append(0) + } + private func cp1252Data(_ string: String) throws -> Data { try #require(string.data(using: .windowsCP1252)) } + + private func header(_ name: String, in xd: String) -> String? { + let prefix = "\(name): " + return xd.split(separator: "\n") + .first { $0.hasPrefix(prefix) } + .map { String($0.dropFirst(prefix.count)) } + } }