/** * Mayan v2 — Peach's proprietary vault backup matrix code. * * Format: a square dark/light module grid with three 7×7 corner anchor * squares (TL, TR, BL — BR absent for orientation), a 16-bit symbol-size * marker adjacent to the TL anchor, two L-shaped timing strips along the * top and left active edges, and the Othalan rune ᛟ at the dead center. * Vault data radiates outward in a row-by-row grid skipping all reserved * positions. * * Pipeline (caller responsibility): plaintext → zstd compress → AES-256-GCM * encrypt → encodeMayan(). Compress-before-encrypt is non-negotiable; the * Peach payload format already does this internally so callers passing * `buildPayload()` output here are compliant. * * Header: 6 bytes — magic 0x4D, version 0x02, payload length u32 LE. * * Reed-Solomon: RS(255,204) — 204 data bytes + 51 parity per block, 20% * overhead. Blocks are interleaved (byte j of block i → position * i + j*numBlocks) so a localized burst of damage spreads across blocks * instead of crushing one. * * The rune is the soul of the format. The anchors are scaffolding that * makes machine decoding reliable — the decoder uses them for homography, * not the rune. See peach_mayan_v2_spec.md for the full rationale. */ import { rsEncodeBlock } from "./rs"; export const MAYAN_MAGIC = 0x4d; export const MAYAN_V2 = 0x02; export const RUNE_ZONE = 27; export const QUIET_ZONE = 2; export const ANCHOR_SIZE = 7; export const ANCHOR_FOOTPRINT = 9; // 7×7 anchor + 1-module light ring on every side export const RS_DATA = 204; export const RS_PARITY = 51; export const RS_BLOCK = RS_DATA + RS_PARITY; // 255 export const HEADER_BYTES = 6; export const MAX_SYMBOL_SIZE = 1024; export const MIN_SYMBOL_SIZE = 64; // Comfortable cap below the 1024-module ceiling. A 1024×1024 symbol holds // ~130 KB of raw data bits; after 20% RS overhead + header that leaves // ~100 KB of payload room with margin to spare. export const MAX_PAYLOAD_BYTES = 100000; export interface MayanResult { canvas: HTMLCanvasElement; symbolSize: number; payloadBytes: number; printable: boolean; paperSize: "A4" | "A3" | "A3-required"; } // ── SIZE CALCULATION ───────────────────────────────────────────────────────── export function calculateSymbolSize(payloadBytes: number): number { const totalData = payloadBytes + HEADER_BYTES; const numBlocks = Math.max(1, Math.ceil(totalData / RS_DATA)); const dataBits = numBlocks * RS_BLOCK * 8; for (let size = MIN_SYMBOL_SIZE; size <= MAX_SYMBOL_SIZE; size++) { if (usableModules(size) >= dataBits) return size; } return MAX_SYMBOL_SIZE; } /** Module budget for data placement at a given symbol size. Subtracts every * reserved region exactly once. */ export function usableModules(size: number): number { let reserved = 0; // Quiet zone: outer 2-module border reserved += quietModuleCount(size); // Three anchors (9×9 footprint each) reserved += 3 * ANCHOR_FOOTPRINT * ANCHOR_FOOTPRINT; // Size bits row (16 modules adjacent to TL anchor, on row=QUIET_ZONE) reserved += 16; // Timing strips (top + left), excluding overlap with rune zone reserved += timingModuleCount(size); // Rune zone (27×27 centered). Subtract any overlap already counted in // timing strips so we don't double-count the rune-zone-vs-timing crossing. reserved += runeModuleCount(size) - runeTimingOverlap(size); return size * size - reserved; } function quietModuleCount(size: number): number { // Four QUIET_ZONE-thick strips minus the corner overlaps return 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE; } function timingModuleCount(size: number): number { // Top strip: row q+ANCHOR_FOOTPRINT, cols [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT) // Left strip: col q+ANCHOR_FOOTPRINT, rows [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT) // Strips don't overlap (one starts at the position the other occupies one off). const span = size - 2 * (QUIET_ZONE + ANCHOR_FOOTPRINT); return Math.max(0, 2 * span); } function runeModuleCount(_size: number): number { return RUNE_ZONE * RUNE_ZONE; } function runeTimingOverlap(size: number): number { // The top timing row crosses the rune zone in 27 modules iff the row index // q+ANCHOR_FOOTPRINT falls within the rune row range. Same for the left // timing column. Compute exactly. const center = Math.floor(size / 2); const half = Math.floor(RUNE_ZONE / 2); const tRow = QUIET_ZONE + ANCHOR_FOOTPRINT; const tCol = QUIET_ZONE + ANCHOR_FOOTPRINT; const rowOverlaps = tRow >= center - half && tRow <= center + half; const colOverlaps = tCol >= center - half && tCol <= center + half; return (rowOverlaps ? RUNE_ZONE : 0) + (colOverlaps ? RUNE_ZONE : 0); } // ── ANCHOR / TIMING / SIZE BIT PLACEMENT ───────────────────────────────────── function drawAnchor(matrix: boolean[][], r0: number, c0: number): void { // Clear 9×9 light ring for (let dr = 0; dr < ANCHOR_FOOTPRINT; dr++) { for (let dc = 0; dc < ANCHOR_FOOTPRINT; dc++) { matrix[r0 + dr][c0 + dc] = false; } } // 7×7 anchor pattern at +1, +1 inside the light ring. // Outer ring dark, gap light, 3×3 dark center. const PAT = [ [1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 1], [1, 0, 1, 1, 1, 0, 1], [1, 0, 1, 1, 1, 0, 1], [1, 0, 1, 1, 1, 0, 1], [1, 0, 0, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1], ]; for (let dr = 0; dr < ANCHOR_SIZE; dr++) { for (let dc = 0; dc < ANCHOR_SIZE; dc++) { matrix[r0 + 1 + dr][c0 + 1 + dc] = PAT[dr][dc] === 1; } } } function placeAnchors(matrix: boolean[][], size: number): void { const q = QUIET_ZONE; drawAnchor(matrix, q, q); drawAnchor(matrix, q, size - q - ANCHOR_FOOTPRINT); drawAnchor(matrix, size - q - ANCHOR_FOOTPRINT, q); // Bottom-right intentionally absent — orientation marker. } function writeSizeBits(matrix: boolean[][], size: number): void { // 16 bits, MSB first, on row=QUIET_ZONE, starting one column to the right // of the TL anchor footprint. Lives inside the quiet-zone-adjacent strip // but outside the anchor light ring. const row = QUIET_ZONE; const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1; for (let i = 0; i < 16; i++) { matrix[row][colStart + i] = ((size >> (15 - i)) & 1) === 1; } } function sizeBitColumns(): { row: number; colStart: number; colEnd: number } { const row = QUIET_ZONE; const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1; return { row, colStart, colEnd: colStart + 16 }; } function placeTimingStrips(matrix: boolean[][], size: number): void { const q = QUIET_ZONE; const start = q + ANCHOR_FOOTPRINT; const end = size - q - ANCHOR_FOOTPRINT; // Top timing strip — alternating dark/light starting dark on even module for (let c = start; c < end; c++) { matrix[start][c] = (c & 1) === 0; } // Left timing strip for (let r = start; r < end; r++) { matrix[r][start] = (r & 1) === 0; } } // ── RUNE DRAWING (unchanged from v1) ───────────────────────────────────────── function fillRect( matrix: boolean[][], r: number, c: number, h: number, w: number, ): void { for (let dr = 0; dr < h; dr++) { for (let dc = 0; dc < w; dc++) { const mr = r + dr; const mc = c + dc; if (mr >= 0 && mr < matrix.length && mc >= 0 && mc < matrix[0].length) { matrix[mr][mc] = true; } } } } function drawDiag( matrix: boolean[][], r0: number, c0: number, r1: number, c1: number, r2: number, c2: number, stroke: number, ): void { const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1)); const half = Math.floor(stroke / 2); for (let i = 0; i <= steps; i++) { const r = Math.round(r1 + ((r2 - r1) * i) / steps); const c = Math.round(c1 + ((c2 - c1) * i) / steps); fillRect(matrix, r0 + r - half, c0 + c - half, stroke, stroke); } } function drawOthalan( matrix: boolean[][], centerRow: number, centerCol: number, ): void { const r0 = centerRow - 11; const c0 = centerCol - 11; const STROKE = 3; const TOP = [1, 11]; const RIGHT = [10, 21]; const BOT = [19, 11]; const LEFT = [10, 1]; drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE); drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE); drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE); drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE); drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE); drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE); // Orientation marker: 3×3 dark block at top-right of rune zone fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE); } // ── RESERVED-POSITION TEST ─────────────────────────────────────────────────── export function isReserved(r: number, c: number, size: number): boolean { const q = QUIET_ZONE; // Quiet zone if (r < q || r >= size - q || c < q || c >= size - q) return true; // Corner anchors (9×9 footprint) const aEnd = q + ANCHOR_FOOTPRINT; if (r < aEnd && c < aEnd) return true; if (r < aEnd && c >= size - aEnd) return true; if (r >= size - aEnd && c < aEnd) return true; // Size bits (row=q, cols colStart..colEnd-1) const sb = sizeBitColumns(); if (r === sb.row && c >= sb.colStart && c < sb.colEnd) return true; // Timing strips const tStart = aEnd; const tEnd = size - aEnd; if (r === tStart && c >= tStart && c < tEnd) return true; if (c === tStart && r >= tStart && r < tEnd) return true; // Rune zone (27×27 centered) const center = Math.floor(size / 2); const half = Math.floor(RUNE_ZONE / 2); if (r >= center - half && r <= center + half && c >= center - half && c <= center + half) return true; return false; } // ── ENCODE PIPELINE ────────────────────────────────────────────────────────── function buildHeaderAndPayload(encryptedPayload: Uint8Array): Uint8Array { const header = new Uint8Array(HEADER_BYTES); header[0] = MAYAN_MAGIC; header[1] = MAYAN_V2; const len = encryptedPayload.length; header[2] = len & 0xff; header[3] = (len >> 8) & 0xff; header[4] = (len >> 16) & 0xff; header[5] = (len >> 24) & 0xff; const out = new Uint8Array(HEADER_BYTES + encryptedPayload.length); out.set(header); out.set(encryptedPayload, HEADER_BYTES); return out; } /** Reed-Solomon encode + interleave. Splits `data` into RS_DATA-byte blocks * (zero-padded last block), encodes each to 255 bytes, then interleaves so * byte j of block i lands at position i + j*numBlocks. */ function rsEncodeAndInterleave(data: Uint8Array): { interleaved: Uint8Array; numBlocks: number } { const numBlocks = Math.ceil(data.length / RS_DATA); const blocks: Uint8Array[] = []; for (let i = 0; i < numBlocks; i++) { const slice = data.subarray(i * RS_DATA, (i + 1) * RS_DATA); const padded = slice.length === RS_DATA ? slice : (() => { const p = new Uint8Array(RS_DATA); p.set(slice); return p; })(); blocks.push(rsEncodeBlock(padded, RS_PARITY)); } const interleaved = new Uint8Array(numBlocks * RS_BLOCK); for (let i = 0; i < numBlocks; i++) { for (let j = 0; j < RS_BLOCK; j++) { interleaved[i + j * numBlocks] = blocks[i][j]; } } return { interleaved, numBlocks }; } /** Build the 2-D module matrix without rendering to canvas. Used by * round-trip tests to skip the binarization step. */ export function encodeMayanMatrix( encryptedPayload: Uint8Array, ): { matrix: boolean[][]; size: number } { if (encryptedPayload.length > MAX_PAYLOAD_BYTES) { throw new Error( `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`, ); } const size = calculateSymbolSize(encryptedPayload.length); if (size > MAX_SYMBOL_SIZE) { throw new Error(`Mayan: symbol size ${size} exceeds max ${MAX_SYMBOL_SIZE}`); } const center = Math.floor(size / 2); const fullData = buildHeaderAndPayload(encryptedPayload); const { interleaved } = rsEncodeAndInterleave(fullData); // Bits, MSB-first per byte const bits: boolean[] = new Array(interleaved.length * 8); for (let bi = 0; bi < interleaved.length; bi++) { const byte = interleaved[bi]; for (let i = 0; i < 8; i++) { bits[bi * 8 + i] = ((byte >> (7 - i)) & 1) === 1; } } // Build matrix const matrix: boolean[][] = Array.from( { length: size }, () => new Array(size).fill(false), ); // Structural elements first; rune drawn last so its pixels win the // crossing region with the timing strips. placeAnchors(matrix, size); writeSizeBits(matrix, size); placeTimingStrips(matrix, size); drawOthalan(matrix, center, center); // Place data bits in row-major scan, skipping reserved positions let bitIdx = 0; for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) { for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) { if (isReserved(r, c, size)) continue; matrix[r][c] = bits[bitIdx++]; } } return { matrix, size }; } export function encodeMayan( encryptedPayload: Uint8Array, modulePx = 3, ): MayanResult { const { matrix, size } = encodeMayanMatrix(encryptedPayload); const canvas = renderMatrix(matrix, size, modulePx); const paperSize: MayanResult["paperSize"] = size <= 350 ? "A4" : size <= 500 ? "A3" : "A3-required"; return { canvas, symbolSize: size, payloadBytes: encryptedPayload.length, printable: paperSize === "A4", paperSize, }; } function renderMatrix( matrix: boolean[][], size: number, modulePx: number, ): HTMLCanvasElement { const canvas = document.createElement("canvas"); canvas.width = size * modulePx; canvas.height = size * modulePx; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Mayan: 2D canvas unavailable"); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#000000"; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { if (matrix[r][c]) { ctx.fillRect(c * modulePx, r * modulePx, modulePx, modulePx); } } } return canvas; } // ── DECODE RE-EXPORTS ──────────────────────────────────────────────────────── // The decoder lives in mayan_decode.ts so the encoder can be imported without // pulling in the homography / image-processing code. Callers that want the // historical `import { decodeMayan } from "./mayan"` ergonomics get it here. export { decodeMayan, decodeMatrix } from "./mayan_decode";