/** * Mayan — Peach's proprietary vault backup matrix code. * * Format: a square dark/light module grid with the Othalan rune ᛟ as the * finder pattern at center. Vault data radiates outward in a row-by-row * grid skipping the reserved center zone and reference grid positions. * * Per peach_mayan_spec.md: * - Magic byte 0x4D ('M'), version 0x01, payload length u16 LE * - 30% Reed-Solomon ECC (block-based RS(255,178), see ./rs.ts) * - 23×23 rune + 2-module padding = 27×27 reserved center * - 2-module quiet zone border * - Reference grid every 32 modules for symbols >128 modules * * Spec §11 reserves the rune as non-negotiable: every Mayan code has the * Othalan ᛟ at its center. Do not replace with QR/Aztec/etc. */ import { reedSolomonEncode, reedSolomonDecode, RS_BLOCK_DATA, RS_BLOCK_TOTAL, } from "./rs"; export const MAYAN_MAGIC = 0x4d; export const MAYAN_VERSION = 0x01; export const RUNE_ZONE = 27; // reserved center: 23×23 rune + 2-module padding each side export const QUIET_ZONE = 2; export const ECC_RATE = 0.30; export const MAX_PAYLOAD_BYTES = 65535; export interface MayanResult { canvas: HTMLCanvasElement; symbolSize: number; payloadBytes: number; printable: boolean; } // ── SIZE CALCULATION ───────────────────────────────────────────────────────── export function calculateSymbolSize(payloadBytes: number): number { const headerBytes = 4; const totalData = payloadBytes + headerBytes; // Use the actual RS-encoded byte count (block-padded) instead of the // theoretical ECC_RATE estimate. The RS codec rounds up to whole 178-byte // data blocks and emits 255 bytes per block, so smaller payloads have // higher effective overhead than the 30% nominal rate. const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA)); const dataBits = blocks * RS_BLOCK_TOTAL * 8; for (let size = 64; size <= 600; size++) { const totalModules = size * size; const centerModules = RUNE_ZONE * RUNE_ZONE; const quietModules = 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE; const refGridModules = size > 128 ? approxReferenceGridCount(size) : 0; const usable = totalModules - centerModules - quietModules - refGridModules; if (usable >= dataBits) return size; } return 600; } function approxReferenceGridCount(size: number): number { // Reference grid lines at every 32nd row and column (excluding quiet zone). // Conservative overestimate: full lines, ignoring intersections with center. const lines = Math.floor((size - 2 * QUIET_ZONE) / 32); const linePos = lines * (size - 2 * QUIET_ZONE); return linePos * 2; } // ── RUNE DRAWING ───────────────────────────────────────────────────────────── 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 { // Stroke diagonal from (r1,c1) to (r2,c2), origin at (r0,c0), as a stair // of stroke×stroke blocks. The half-offset centers the stroke on the line. 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 { // 23×23 region centered on (centerRow, centerCol). Origin = top-left. const r0 = centerRow - 11; const c0 = centerCol - 11; const STROKE = 3; // Diamond corners (within the 23×23 region) 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); // Two legs descending from below the diamond 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, center: number, size: number, ): boolean { if (r < QUIET_ZONE || r >= size - QUIET_ZONE) return true; if (c < QUIET_ZONE || c >= size - QUIET_ZONE) return true; // 27×27 center zone const half = Math.floor(RUNE_ZONE / 2); if ( r >= center - half && r <= center + half && c >= center - half && c <= center + half ) return true; // Reference grid if (size > 128) { if ( r >= QUIET_ZONE && r < size - QUIET_ZONE && (r - QUIET_ZONE) % 32 === 0 && r !== QUIET_ZONE ) return true; if ( c >= QUIET_ZONE && c < size - QUIET_ZONE && (c - QUIET_ZONE) % 32 === 0 && c !== QUIET_ZONE ) return true; } return false; } // ── MATRIX BUILD ───────────────────────────────────────────────────────────── export function encodeMayan( encryptedPayload: Uint8Array, modulePx = 3, ): MayanResult { 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); const center = Math.floor(size / 2); // Mode header (4 bytes): magic | version | length-lo | length-hi const header = new Uint8Array(4); header[0] = MAYAN_MAGIC; header[1] = MAYAN_VERSION; header[2] = encryptedPayload.length & 0xff; header[3] = (encryptedPayload.length >> 8) & 0xff; const fullData = new Uint8Array(header.length + encryptedPayload.length); fullData.set(header); fullData.set(encryptedPayload, header.length); const withECC = reedSolomonEncode(fullData); // Bits, MSB-first per byte const bits: boolean[] = []; for (const byte of withECC) { for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1); } // Build matrix const matrix: boolean[][] = Array.from( { length: size }, () => new Array(size).fill(false), ); // Reference grid (alternating dark/light in fixed phase) if (size > 128) { for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) { for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) { const dark = (p % 2) === 0; matrix[line][p] = dark; matrix[p][line] = dark; } } } drawOthalan(matrix, center, center); // Place data bits 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, center, size)) continue; matrix[r][c] = bits[bitIdx++]; } } const canvas = renderMatrix(matrix, size, modulePx); const printable = size * 0.6 <= 210; // fits on A4 width at 0.6mm/module return { canvas, symbolSize: size, payloadBytes: encryptedPayload.length, printable, }; } 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; } /** Expose the matrix in addition to the canvas — for round-trip tests that * 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); const center = Math.floor(size / 2); const header = new Uint8Array(4); header[0] = MAYAN_MAGIC; header[1] = MAYAN_VERSION; header[2] = encryptedPayload.length & 0xff; header[3] = (encryptedPayload.length >> 8) & 0xff; const fullData = new Uint8Array(header.length + encryptedPayload.length); fullData.set(header); fullData.set(encryptedPayload, header.length); const withECC = reedSolomonEncode(fullData); const bits: boolean[] = []; for (const byte of withECC) { for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1); } const matrix: boolean[][] = Array.from( { length: size }, () => new Array(size).fill(false), ); if (size > 128) { for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) { for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) { const dark = (p % 2) === 0; matrix[line][p] = dark; matrix[p][line] = dark; } } } drawOthalan(matrix, center, center); 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, center, size)) continue; matrix[r][c] = bits[bitIdx++]; } } return { matrix, size }; } // ── DECODER ────────────────────────────────────────────────────────────────── /** Decode a Mayan code from raw image data. * Returns null when the image isn't a Mayan code (no rune found, magic byte * mismatch, or unknown version) or when RS correction fails. */ export function decodeMayan(imageData: ImageData): Uint8Array | null { const binary = adaptiveBinarize(imageData); const W = imageData.width; const H = imageData.height; const rune = findOthalan(binary, W, H); if (!rune) return null; const modulePx = estimateModuleSize(binary, rune, W); if (!modulePx || modulePx < 1) return null; const symbolModules = Math.round(Math.min(W, H) / modulePx); if (symbolModules < 64) return null; const rotation = detectRotation(binary, rune, modulePx); const matrix = sampleModuleGrid( binary, { x: rune.cx, y: rune.cy }, modulePx, symbolModules, rotation, ); return decodeMatrix(matrix, symbolModules); } /** Decode a Mayan code from a sampled module matrix (for tests). */ export function decodeMatrix( matrix: boolean[][], symbolModules: number, ): Uint8Array | null { const center = Math.floor(symbolModules / 2); // Read the first 32 data bits to recover the mode header const headerBits = readDataBits(matrix, center, symbolModules, 32); if (headerBits.length < 32) return null; const headerBytes = bitsToBytes(headerBits); if (headerBytes[0] !== MAYAN_MAGIC) return null; if (headerBytes[1] !== MAYAN_VERSION) return null; const payloadLength = headerBytes[2] | (headerBytes[3] << 8); if (payloadLength > MAX_PAYLOAD_BYTES) return null; // Read all data bits — round up to whole RS(255,178) blocks const totalDataBytes = 4 + payloadLength; const blocks = Math.ceil(totalDataBytes / 178); const totalBits = blocks * 255 * 8; const allBits = readDataBits(matrix, center, symbolModules, totalBits); if (allBits.length < totalBits) return null; const rawBytes = new Uint8Array(bitsToBytes(allBits)); let decoded: Uint8Array; try { decoded = reedSolomonDecode(rawBytes, totalDataBytes); } catch { return null; } return decoded.slice(4, 4 + payloadLength); } // ── BINARIZATION ───────────────────────────────────────────────────────────── function adaptiveBinarize(imageData: ImageData): boolean[][] { const { data, width, height } = imageData; const out: boolean[][] = Array.from( { length: height }, () => new Array(width).fill(false), ); const R = 9; // Integral image of grayscale for O(1) box mean per pixel. const integral = new Float64Array((width + 1) * (height + 1)); for (let y = 0; y < height; y++) { let rowSum = 0; for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; rowSum += gray; integral[(y + 1) * (width + 1) + (x + 1)] = integral[y * (width + 1) + (x + 1)] + rowSum; } } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; const x0 = Math.max(0, x - R); const y0 = Math.max(0, y - R); const x1 = Math.min(width - 1, x + R); const y1 = Math.min(height - 1, y + R); const sum = integral[(y1 + 1) * (width + 1) + (x1 + 1)] - integral[(y0) * (width + 1) + (x1 + 1)] - integral[(y1 + 1) * (width + 1) + (x0)] + integral[(y0) * (width + 1) + (x0)]; const count = (x1 - x0 + 1) * (y1 - y0 + 1); out[y][x] = gray < sum / count - 8; } } return out; } // ── RUNE LOCATION ─────────────────────────────────────────────────────────── function findOthalan( binary: boolean[][], W: number, H: number, ): { cx: number; cy: number; size: number } | null { // Scan horizontally for a dark-light-dark pattern with the diamond's // expected proportions: each side ~3 modules, interior ≥ 1.5× side. const candidates: { cx: number; cy: number; size: number }[] = []; const yStart = Math.floor(H * 0.2); const yEnd = Math.floor(H * 0.8); for (let y = yStart; y < yEnd; y += 2) { let x = 0; while (x < W) { while (x < W && !binary[y][x]) x++; if (x >= W) break; const darkStart = x; while (x < W && binary[y][x]) x++; const darkLen = x - darkStart; const lightStart = x; while (x < W && !binary[y][x]) x++; const lightLen = x - lightStart; const dark2Start = x; while (x < W && binary[y][x]) x++; const dark2Len = x - dark2Start; if ( darkLen >= 2 && dark2Len >= 2 && Math.abs(darkLen - dark2Len) <= darkLen * 0.5 && lightLen > darkLen * 1.5 ) { const cx = Math.floor((darkStart + x) / 2); const size = x - darkStart; candidates.push({ cx, cy: y, size }); } } } if (candidates.length < 3) return null; // Cluster by (cx, size) proximity and pick the strongest cluster. const best = candidates.reduce((a, b) => (a.size > b.size ? a : b)); // Refine cy by scanning vertically through the diamond's horizontal axis. let cyTop = best.cy; while (cyTop > 0 && binary[cyTop][best.cx]) cyTop--; let cyBot = best.cy; while ( cyBot < H - 1 && (binary[cyBot][best.cx] || (best.cx > 0 && binary[cyBot][best.cx - 1]) || (best.cx < W - 1 && binary[cyBot][best.cx + 1])) ) cyBot++; const cy = Math.floor((cyTop + cyBot) / 2); return { cx: best.cx, cy, size: best.size }; } function estimateModuleSize( binary: boolean[][], rune: { cx: number; cy: number; size: number }, W: number, ): number { const y = rune.cy; const strokeWidths: number[] = []; for (let x = Math.max(0, rune.cx - rune.size); x < rune.cx + rune.size; x++) { if (x >= W) break; if (binary[y][x] && (x === 0 || !binary[y][x - 1])) { let runLen = 0; let xx = x; while (xx < W && binary[y][xx]) { runLen++; xx++; } if (runLen > 1 && runLen < rune.size / 2) strokeWidths.push(runLen); } } if (strokeWidths.length === 0) return rune.size / 23; const avg = strokeWidths.reduce((a, b) => a + b, 0) / strokeWidths.length; return avg / 3; } function detectRotation( binary: boolean[][], rune: { cx: number; cy: number; size: number }, modulePx: number, ): number { // Orientation marker is a 3×3 dark block at (rune-row 1-3, rune-col 20-22) // = 10 modules right and 10 modules above center at 0°. const offsets: { angle: number; dr: number; dc: number }[] = [ { angle: 0, dr: -10, dc: +10 }, { angle: 90, dr: +10, dc: +10 }, { angle: 180, dr: +10, dc: -10 }, { angle: 270, dr: -10, dc: -10 }, ]; let bestAngle = 0; let bestScore = -1; for (const { angle, dr, dc } of offsets) { const pr = Math.round(rune.cy + dr * modulePx); const pc = Math.round(rune.cx + dc * modulePx); let darkCount = 0; for (let dr2 = -1; dr2 <= 1; dr2++) { for (let dc2 = -1; dc2 <= 1; dc2++) { const r = pr + Math.round(dr2 * modulePx); const c = pc + Math.round(dc2 * modulePx); if (r >= 0 && r < binary.length && c >= 0 && c < binary[0].length) { if (binary[r][c]) darkCount++; } } } if (darkCount > bestScore) { bestScore = darkCount; bestAngle = angle; } } return bestAngle; } function sampleModuleGrid( binary: boolean[][], center: { x: number; y: number }, modulePx: number, symbolModules: number, rotation: number, ): boolean[][] { const half = Math.floor(symbolModules / 2); const matrix: boolean[][] = Array.from( { length: symbolModules }, () => new Array(symbolModules).fill(false), ); for (let r = 0; r < symbolModules; r++) { for (let c = 0; c < symbolModules; c++) { let dr = r - half; let dc = c - half; if (rotation === 90) { [dr, dc] = [dc, -dr]; } if (rotation === 180) { [dr, dc] = [-dr, -dc]; } if (rotation === 270) { [dr, dc] = [-dc, dr]; } const px = Math.round(center.x + dc * modulePx); const py = Math.round(center.y + dr * modulePx); if ( py >= 0 && py < binary.length && px >= 0 && px < binary[0].length ) matrix[r][c] = binary[py][px]; } } return matrix; } function readDataBits( matrix: boolean[][], center: number, size: number, count: number, ): boolean[] { const bits: boolean[] = []; for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) { for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) { if (isReserved(r, c, center, size)) continue; bits.push(matrix[r][c]); } } return bits; } function bitsToBytes(bits: boolean[]): number[] { const bytes: number[] = []; for (let i = 0; i < bits.length; i += 8) { let byte = 0; for (let j = 0; j < 8 && i + j < bits.length; j++) { if (bits[i + j]) byte |= 1 << (7 - j); } bytes.push(byte); } return bytes; }