3673ad98ff49

feat(mayan): v2 — corner anchors, homography decoder, RS(255,204), v1 dropped (v2.25.0)

time
2026-05-01 19:04 UTC · 5 months ago
machine
koh2
parent
47945ca845b0
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/mayan.test.ts+88 −34src/crypto/mayan.ts+261 −454src/crypto/mayan_decode.ts+659 −0src/crypto/rs.ts+27 −15src/popup/views/Export.tsx+6 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.24.0",4+  "version": "2.25.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.24.0",4+  "version": "2.25.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.19.0",3+  "version": "2.25.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/crypto/mayan.test.ts+88 −34
@@ -1,20 +1,24 @@11 import { describe, it, expect } from "vitest";22 import {33   MAYAN_MAGIC,4-  MAYAN_VERSION,4+  MAYAN_V2,55   MAX_PAYLOAD_BYTES,66   RUNE_ZONE,77   QUIET_ZONE,8+  ANCHOR_FOOTPRINT,9+  RS_DATA,10+  RS_BLOCK,11+  HEADER_BYTES,812   calculateSymbolSize,913   encodeMayanMatrix,14+  encodeMayan,1015   decodeMatrix,1116   decodeMayan,1217   isReserved,1318 } from "./mayan";1419 1520 // Synthesize an ImageData from a boolean module matrix at a given module pixel16-// size. Pure black/white, no border padding. Useful for exercising the full17-// image-decode pipeline without a canvas.21+// size. Pure black/white, no border padding.1822 function matrixToImageData(1923   matrix: boolean[][],2024   size: number,@@ -43,7 +47,7 @@4347   return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;4448 }4549 46-// Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.50+// Deterministic PRNG — Mulberry32 — so tests are reproducible.4751 function mulberry32(seed: number) {4852   let state = seed >>> 0;4953   return () => {@@ -68,11 +72,10 @@6872   fraction: number,6973   seed = 1,7074 ): boolean[][] {71-  const center = Math.floor(size / 2);7275   const dataPositions: { r: number; c: number }[] = [];7376   for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {7477     for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {75-      if (!isReserved(r, c, center, size)) dataPositions.push({ r, c });78+      if (!isReserved(r, c, size)) dataPositions.push({ r, c });7679     }7780   }7881   const rng = mulberry32(seed);@@ -87,22 +90,26 @@8790   return out;8891 }8992 90-describe("Mayan format", () => {93+describe("Mayan v2 format", () => {9194   it("constants are coherent", () => {9295     expect(MAYAN_MAGIC).toBe(0x4d);93-    expect(MAYAN_VERSION).toBe(0x01);96+    expect(MAYAN_V2).toBe(0x02);9497     expect(RUNE_ZONE).toBe(27);9598     expect(QUIET_ZONE).toBe(2);99+    expect(ANCHOR_FOOTPRINT).toBe(9);100+    expect(RS_DATA).toBe(204);101+    expect(RS_BLOCK).toBe(255);102+    expect(HEADER_BYTES).toBe(6);96103   });97104 98105   it("calculateSymbolSize returns >=64 for tiny payloads", () => {99-    expect(calculateSymbolSize(0)).toBe(64);106+    expect(calculateSymbolSize(0)).toBeGreaterThanOrEqual(64);100107     expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);101108   });102109 103110   it("calculateSymbolSize grows with payload", () => {104111     const small = calculateSymbolSize(100);105-    const big = calculateSymbolSize(5000);112+    const big = calculateSymbolSize(50_000);106113     expect(big).toBeGreaterThan(small);107114   });108115 @@ -110,9 +117,48 @@110117     const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);111118     expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);112119   });120+121+  it("isReserved marks anchor footprints as reserved", () => {122+    const size = 128;123+    // Top-left anchor footprint: rows/cols [QUIET_ZONE..QUIET_ZONE+8]124+    expect(isReserved(QUIET_ZONE, QUIET_ZONE, size)).toBe(true);125+    expect(isReserved(QUIET_ZONE + 5, QUIET_ZONE + 5, size)).toBe(true);126+    expect(isReserved(QUIET_ZONE + 8, QUIET_ZONE + 8, size)).toBe(true);127+    // Just outside the anchor footprint should be data area (assuming the128+    // timing strip and rune zone don't claim it)129+    const safeR = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;130+    const safeC = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;131+    // The corner (anchorEnd, anchorEnd) lies at the timing-strip intersection,132+    // so step one further in133+    expect(isReserved(safeR, safeC, size)).toBe(false);134+  });135+136+  it("isReserved marks size-bit row positions as reserved", () => {137+    const size = 128;138+    const row = QUIET_ZONE;139+    const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;140+    for (let i = 0; i < 16; i++) {141+      expect(isReserved(row, colStart + i, size)).toBe(true);142+    }143+  });144+145+  it("encodes the symbol size bits round-trippably", () => {146+    const payload = randomPayload(50);147+    const { matrix, size } = encodeMayanMatrix(payload);148+    const row = QUIET_ZONE;149+    const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;150+    let v = 0;151+    for (let i = 0; i < 16; i++) {152+      if (matrix[row][colStart + i]) v |= 1 << (15 - i);153+    }154+    expect(v).toBe(size);155+  });156+157+  // encodeMayan() requires DOM canvas (only available in browser/jsdom).158+  // Paper-size tagging is exercised end-to-end via the Export view.113159 });114160 115-describe("Mayan round-trip", () => {161+describe("Mayan v2 round-trip (matrix)", () => {116162   it("round-trips a 5-byte payload", () => {117163     const payload = new Uint8Array([1, 2, 3, 4, 5]);118164     const { matrix, size } = encodeMayanMatrix(payload);@@ -145,6 +191,14 @@145191     expect(Array.from(decoded!)).toEqual(Array.from(payload));146192   });147193 194+  it("round-trips a 20000-byte payload (realistic vault size)", () => {195+    const payload = randomPayload(20000);196+    const { matrix, size } = encodeMayanMatrix(payload);197+    const decoded = decodeMatrix(matrix, size);198+    expect(decoded).not.toBeNull();199+    expect(Array.from(decoded!)).toEqual(Array.from(payload));200+  });201+148202   it("rejects a matrix with no Mayan magic byte", () => {149203     const fake: boolean[][] = Array.from({ length: 64 }, () =>150204       new Array<boolean>(64).fill(false),@@ -154,8 +208,8 @@154208   });155209 });156210 157-describe("Mayan image pipeline", () => {158-  it("decodes a 5-byte payload via the full image pipeline at modulePx=3", () => {211+describe("Mayan v2 image pipeline", () => {212+  it("decodes a 5-byte payload at modulePx=3", () => {159213     const payload = new Uint8Array([1, 2, 3, 4, 5]);160214     const { matrix, size } = encodeMayanMatrix(payload);161215     const imgData = matrixToImageData(matrix, size, 3);@@ -164,7 +218,7 @@164218     expect(Array.from(decoded!)).toEqual(Array.from(payload));165219   });166220 167-  it("decodes a 100-byte payload via the full image pipeline at modulePx=3", () => {221+  it("decodes a 100-byte payload at modulePx=3", () => {168222     const payload = randomPayload(100);169223     const { matrix, size } = encodeMayanMatrix(payload);170224     const imgData = matrixToImageData(matrix, size, 3);@@ -173,7 +227,7 @@173227     expect(Array.from(decoded!)).toEqual(Array.from(payload));174228   });175229 176-  it("decodes a 100-byte payload via the full image pipeline at modulePx=4", () => {230+  it("decodes a 100-byte payload at modulePx=4", () => {177231     const payload = randomPayload(100);178232     const { matrix, size } = encodeMayanMatrix(payload);179233     const imgData = matrixToImageData(matrix, size, 4);@@ -182,46 +236,46 @@182236     expect(Array.from(decoded!)).toEqual(Array.from(payload));183237   });184238 185-  it("decodes a 5000-byte payload via the full image pipeline at modulePx=4", () => {186-    const payload = randomPayload(5000);239+  it("decodes a 500-byte payload at modulePx=3", () => {240+    const payload = randomPayload(500);187241     const { matrix, size } = encodeMayanMatrix(payload);188-    const imgData = matrixToImageData(matrix, size, 4);242+    const imgData = matrixToImageData(matrix, size, 3);189243     const decoded = decodeMayan(imgData);190244     expect(decoded).not.toBeNull();191245     expect(Array.from(decoded!)).toEqual(Array.from(payload));192246   });193247 194-  it("decodes a near-max payload (20000 bytes) at modulePx=4 — the realistic backup case", () => {195-    const payload = randomPayload(20000);248+  it("decodes a 5000-byte payload at modulePx=4", () => {249+    const payload = randomPayload(5000);196250     const { matrix, size } = encodeMayanMatrix(payload);197-    console.log(`20000-byte payload → size=${size}`);198251     const imgData = matrixToImageData(matrix, size, 4);199252     const decoded = decodeMayan(imgData);200253     expect(decoded).not.toBeNull();201254     expect(Array.from(decoded!)).toEqual(Array.from(payload));202255   });256+});203257 204-  it("decodes a 500-byte payload via the full image pipeline at modulePx=3", () => {205-    const payload = randomPayload(500);258+describe("Mayan v2 RS recovery", () => {259+  it("recovers from 0.5% random bit flips in the data area", () => {260+    const payload = randomPayload(50);206261     const { matrix, size } = encodeMayanMatrix(payload);207-    const imgData = matrixToImageData(matrix, size, 3);208-    const decoded = decodeMayan(imgData);262+    const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);263+    const decoded = decodeMatrix(corrupted, size);209264     expect(decoded).not.toBeNull();210265     expect(Array.from(decoded!)).toEqual(Array.from(payload));211266   });212-});213267 214-describe("Mayan RS recovery", () => {215-  // Recoverable threshold for RS(255,178): up to 38 byte errors per block.216-  // Each byte holds 8 bits, so the bit-flip equivalent is bounded by the217-  // byte coverage P(byte has ≥1 flip) = 1 - (1-p)^8. For 30% byte errors218-  // (within RS limit), p ≈ 0.043 — so 1% bit flips is comfortably inside.219-  it("recovers from 0.5% random bit flips in data area", () => {220-    const payload = randomPayload(50);268+  it("recovers from 1% random bit flips on a multi-block payload", () => {269+    // RS(255,204) corrects up to 25 byte errors per block. With interleaving270+    // a 1% bit flip rate translates to ~7 byte errors per block — comfortably271+    // within capacity. Higher rates approach the per-block limit and are272+    // exercised in stress benchmarks separately.273+    const payload = randomPayload(500);221274     const { matrix, size } = encodeMayanMatrix(payload);222-    const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);275+    const corrupted = flipRandomDataBits(matrix, size, 0.01, 7);223276     const decoded = decodeMatrix(corrupted, size);224277     expect(decoded).not.toBeNull();225278     expect(Array.from(decoded!)).toEqual(Array.from(payload));226279   });227280 });281+

view file

src/crypto/mayan.ts+261 −454
@@ -1,77 +1,186 @@11 /**2- * Mayan — Peach's proprietary vault backup matrix code.2+ * Mayan v2 — Peach's proprietary vault backup matrix code.33  *4- * Format: a square dark/light module grid with the Othalan rune ᛟ as the5- * finder pattern at center. Vault data radiates outward in a row-by-row6- * grid skipping the reserved center zone and reference grid positions.4+ * Format: a square dark/light module grid with three 7×7 corner anchor5+ * squares (TL, TR, BL — BR absent for orientation), a 16-bit symbol-size6+ * marker adjacent to the TL anchor, two L-shaped timing strips along the7+ * top and left active edges, and the Othalan rune ᛟ at the dead center.8+ * Vault data radiates outward in a row-by-row grid skipping all reserved9+ * positions.710  *8- * Per peach_mayan_spec.md:9- *   - Magic byte 0x4D ('M'), version 0x01, payload length u16 LE10- *   - 30% Reed-Solomon ECC (block-based RS(255,178), see ./rs.ts)11- *   - 23×23 rune + 2-module padding = 27×27 reserved center12- *   - 2-module quiet zone border13- *   - Reference grid every 32 modules for symbols >128 modules11+ * Pipeline (caller responsibility): plaintext → zstd compress → AES-256-GCM12+ * encrypt → encodeMayan(). Compress-before-encrypt is non-negotiable; the13+ * Peach payload format already does this internally so callers passing14+ * `buildPayload()` output here are compliant.1415  *15- * Spec §11 reserves the rune as non-negotiable: every Mayan code has the16- * Othalan ᛟ at its center. Do not replace with QR/Aztec/etc.16+ * Header: 6 bytes — magic 0x4D, version 0x02, payload length u32 LE.17+ *18+ * Reed-Solomon: RS(255,204) — 204 data bytes + 51 parity per block, 20%19+ * overhead. Blocks are interleaved (byte j of block i → position20+ * i + j*numBlocks) so a localized burst of damage spreads across blocks21+ * instead of crushing one.22+ *23+ * The rune is the soul of the format. The anchors are scaffolding that24+ * makes machine decoding reliable — the decoder uses them for homography,25+ * not the rune. See peach_mayan_v2_spec.md for the full rationale.1726  */1827 19-import {20-  reedSolomonEncode,21-  reedSolomonDecode,22-  RS_BLOCK_DATA,23-  RS_BLOCK_TOTAL,24-} from "./rs";28+import { rsEncodeBlock } from "./rs";2529 2630 export const MAYAN_MAGIC = 0x4d;27-export const MAYAN_VERSION = 0x01;28-export const RUNE_ZONE = 27;     // reserved center: 23×23 rune + 2-module padding each side31+export const MAYAN_V2 = 0x02;32+export const RUNE_ZONE = 27;2933 export const QUIET_ZONE = 2;30-export const ECC_RATE = 0.30;31-export const MAX_PAYLOAD_BYTES = 65535;34+export const ANCHOR_SIZE = 7;35+export const ANCHOR_FOOTPRINT = 9; // 7×7 anchor + 1-module light ring on every side36+export const RS_DATA = 204;37+export const RS_PARITY = 51;38+export const RS_BLOCK = RS_DATA + RS_PARITY; // 25539+export const HEADER_BYTES = 6;40+export const MAX_SYMBOL_SIZE = 1024;41+export const MIN_SYMBOL_SIZE = 64;42+// Comfortable cap below the 1024-module ceiling. A 1024×1024 symbol holds43+// ~130 KB of raw data bits; after 20% RS overhead + header that leaves44+// ~100 KB of payload room with margin to spare.45+export const MAX_PAYLOAD_BYTES = 100000;3246 3347 export interface MayanResult {3448   canvas: HTMLCanvasElement;3549   symbolSize: number;3650   payloadBytes: number;3751   printable: boolean;52+  paperSize: "A4" | "A3" | "A3-required";3853 }3954 4055 // ── SIZE CALCULATION ─────────────────────────────────────────────────────────4156 4257 export function calculateSymbolSize(payloadBytes: number): number {43-  const headerBytes = 4;44-  const totalData = payloadBytes + headerBytes;45-  // Use the actual RS-encoded byte count (block-padded) instead of the46-  // theoretical ECC_RATE estimate. The RS codec rounds up to whole 178-byte47-  // data blocks and emits 255 bytes per block, so smaller payloads have48-  // higher effective overhead than the 30% nominal rate.49-  const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA));50-  const dataBits = blocks * RS_BLOCK_TOTAL * 8;58+  const totalData = payloadBytes + HEADER_BYTES;59+  const numBlocks = Math.max(1, Math.ceil(totalData / RS_DATA));60+  const dataBits = numBlocks * RS_BLOCK * 8;5161 52-  for (let size = 64; size <= 600; size++) {53-    const totalModules = size * size;54-    const centerModules = RUNE_ZONE * RUNE_ZONE;55-    const quietModules = 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;56-    const refGridModules = size > 12857-      ? approxReferenceGridCount(size)58-      : 0;59-    const usable = totalModules - centerModules - quietModules - refGridModules;60-    if (usable >= dataBits) return size;62+  for (let size = MIN_SYMBOL_SIZE; size <= MAX_SYMBOL_SIZE; size++) {63+    if (usableModules(size) >= dataBits) return size;6164   }62-  return 600;65+  return MAX_SYMBOL_SIZE;6366 }6467 65-function approxReferenceGridCount(size: number): number {66-  // Reference grid lines at every 32nd row and column (excluding quiet zone).67-  // Conservative overestimate: full lines, ignoring intersections with center.68-  const lines = Math.floor((size - 2 * QUIET_ZONE) / 32);69-  const linePos = lines * (size - 2 * QUIET_ZONE);70-  return linePos * 2;68+/** Module budget for data placement at a given symbol size. Subtracts every69+ *  reserved region exactly once. */70+export function usableModules(size: number): number {71+  let reserved = 0;72+  // Quiet zone: outer 2-module border73+  reserved += quietModuleCount(size);74+  // Three anchors (9×9 footprint each)75+  reserved += 3 * ANCHOR_FOOTPRINT * ANCHOR_FOOTPRINT;76+  // Size bits row (16 modules adjacent to TL anchor, on row=QUIET_ZONE)77+  reserved += 16;78+  // Timing strips (top + left), excluding overlap with rune zone79+  reserved += timingModuleCount(size);80+  // Rune zone (27×27 centered). Subtract any overlap already counted in81+  // timing strips so we don't double-count the rune-zone-vs-timing crossing.82+  reserved += runeModuleCount(size) - runeTimingOverlap(size);83+  return size * size - reserved;7184 }7285 73-// ── RUNE DRAWING ─────────────────────────────────────────────────────────────86+function quietModuleCount(size: number): number {87+  // Four QUIET_ZONE-thick strips minus the corner overlaps88+  return 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;89+}7490 91+function timingModuleCount(size: number): number {92+  // Top strip: row q+ANCHOR_FOOTPRINT, cols [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)93+  // Left strip: col q+ANCHOR_FOOTPRINT, rows [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)94+  // Strips don't overlap (one starts at the position the other occupies one off).95+  const span = size - 2 * (QUIET_ZONE + ANCHOR_FOOTPRINT);96+  return Math.max(0, 2 * span);97+}98+99+function runeModuleCount(_size: number): number {100+  return RUNE_ZONE * RUNE_ZONE;101+}102+103+function runeTimingOverlap(size: number): number {104+  // The top timing row crosses the rune zone in 27 modules iff the row index105+  // q+ANCHOR_FOOTPRINT falls within the rune row range. Same for the left106+  // timing column. Compute exactly.107+  const center = Math.floor(size / 2);108+  const half = Math.floor(RUNE_ZONE / 2);109+  const tRow = QUIET_ZONE + ANCHOR_FOOTPRINT;110+  const tCol = QUIET_ZONE + ANCHOR_FOOTPRINT;111+  const rowOverlaps = tRow >= center - half && tRow <= center + half;112+  const colOverlaps = tCol >= center - half && tCol <= center + half;113+  return (rowOverlaps ? RUNE_ZONE : 0) + (colOverlaps ? RUNE_ZONE : 0);114+}115+116+// ── ANCHOR / TIMING / SIZE BIT PLACEMENT ─────────────────────────────────────117+118+function drawAnchor(matrix: boolean[][], r0: number, c0: number): void {119+  // Clear 9×9 light ring120+  for (let dr = 0; dr < ANCHOR_FOOTPRINT; dr++) {121+    for (let dc = 0; dc < ANCHOR_FOOTPRINT; dc++) {122+      matrix[r0 + dr][c0 + dc] = false;123+    }124+  }125+  // 7×7 anchor pattern at +1, +1 inside the light ring.126+  // Outer ring dark, gap light, 3×3 dark center.127+  const PAT = [128+    [1, 1, 1, 1, 1, 1, 1],129+    [1, 0, 0, 0, 0, 0, 1],130+    [1, 0, 1, 1, 1, 0, 1],131+    [1, 0, 1, 1, 1, 0, 1],132+    [1, 0, 1, 1, 1, 0, 1],133+    [1, 0, 0, 0, 0, 0, 1],134+    [1, 1, 1, 1, 1, 1, 1],135+  ];136+  for (let dr = 0; dr < ANCHOR_SIZE; dr++) {137+    for (let dc = 0; dc < ANCHOR_SIZE; dc++) {138+      matrix[r0 + 1 + dr][c0 + 1 + dc] = PAT[dr][dc] === 1;139+    }140+  }141+}142+143+function placeAnchors(matrix: boolean[][], size: number): void {144+  const q = QUIET_ZONE;145+  drawAnchor(matrix, q, q);146+  drawAnchor(matrix, q, size - q - ANCHOR_FOOTPRINT);147+  drawAnchor(matrix, size - q - ANCHOR_FOOTPRINT, q);148+  // Bottom-right intentionally absent — orientation marker.149+}150+151+function writeSizeBits(matrix: boolean[][], size: number): void {152+  // 16 bits, MSB first, on row=QUIET_ZONE, starting one column to the right153+  // of the TL anchor footprint. Lives inside the quiet-zone-adjacent strip154+  // but outside the anchor light ring.155+  const row = QUIET_ZONE;156+  const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;157+  for (let i = 0; i < 16; i++) {158+    matrix[row][colStart + i] = ((size >> (15 - i)) & 1) === 1;159+  }160+}161+162+function sizeBitColumns(): { row: number; colStart: number; colEnd: number } {163+  const row = QUIET_ZONE;164+  const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;165+  return { row, colStart, colEnd: colStart + 16 };166+}167+168+function placeTimingStrips(matrix: boolean[][], size: number): void {169+  const q = QUIET_ZONE;170+  const start = q + ANCHOR_FOOTPRINT;171+  const end = size - q - ANCHOR_FOOTPRINT;172+  // Top timing strip — alternating dark/light starting dark on even module173+  for (let c = start; c < end; c++) {174+    matrix[start][c] = (c & 1) === 0;175+  }176+  // Left timing strip177+  for (let r = start; r < end; r++) {178+    matrix[r][start] = (r & 1) === 0;179+  }180+}181+182+// ── RUNE DRAWING (unchanged from v1) ─────────────────────────────────────────183+75184 function fillRect(76185   matrix: boolean[][],77186   r: number,@@ -100,8 +209,6 @@100209   c2: number,101210   stroke: number,102211 ): void {103-  // Stroke diagonal from (r1,c1) to (r2,c2), origin at (r0,c0), as a stair104-  // of stroke×stroke blocks. The half-offset centers the stroke on the line.105212   const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1));106213   const half = Math.floor(stroke / 2);107214   for (let i = 0; i <= steps; i++) {@@ -116,93 +223,119 @@116223   centerRow: number,117224   centerCol: number,118225 ): void {119-  // 23×23 region centered on (centerRow, centerCol). Origin = top-left.120226   const r0 = centerRow - 11;121227   const c0 = centerCol - 11;122228   const STROKE = 3;123-124-  // Diamond corners (within the 23×23 region)125229   const TOP   = [1, 11];126230   const RIGHT = [10, 21];127231   const BOT   = [19, 11];128232   const LEFT  = [10, 1];129-130233   drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE);131234   drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE);132235   drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE);133236   drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE);134-135-  // Two legs descending from below the diamond136237   drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE);137238   drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE);138-139239   // Orientation marker: 3×3 dark block at top-right of rune zone140240   fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE);141241 }142242 143243 // ── RESERVED-POSITION TEST ───────────────────────────────────────────────────144244 145-export function isReserved(146-  r: number,147-  c: number,148-  center: number,149-  size: number,150-): boolean {151-  if (r < QUIET_ZONE || r >= size - QUIET_ZONE) return true;152-  if (c < QUIET_ZONE || c >= size - QUIET_ZONE) return true;153-  // 27×27 center zone245+export function isReserved(r: number, c: number, size: number): boolean {246+  const q = QUIET_ZONE;247+  // Quiet zone248+  if (r < q || r >= size - q || c < q || c >= size - q) return true;249+250+  // Corner anchors (9×9 footprint)251+  const aEnd = q + ANCHOR_FOOTPRINT;252+  if (r < aEnd && c < aEnd) return true;253+  if (r < aEnd && c >= size - aEnd) return true;254+  if (r >= size - aEnd && c < aEnd) return true;255+256+  // Size bits (row=q, cols colStart..colEnd-1)257+  const sb = sizeBitColumns();258+  if (r === sb.row && c >= sb.colStart && c < sb.colEnd) return true;259+260+  // Timing strips261+  const tStart = aEnd;262+  const tEnd = size - aEnd;263+  if (r === tStart && c >= tStart && c < tEnd) return true;264+  if (c === tStart && r >= tStart && r < tEnd) return true;265+266+  // Rune zone (27×27 centered)267+  const center = Math.floor(size / 2);154268   const half = Math.floor(RUNE_ZONE / 2);155-  if (156-    r >= center - half &&157-    r <= center + half &&158-    c >= center - half &&159-    c <= center + half160-  ) return true;161-  // Reference grid162-  if (size > 128) {163-    if (164-      r >= QUIET_ZONE && r < size - QUIET_ZONE && (r - QUIET_ZONE) % 32 === 0 &&165-      r !== QUIET_ZONE166-    ) return true;167-    if (168-      c >= QUIET_ZONE && c < size - QUIET_ZONE && (c - QUIET_ZONE) % 32 === 0 &&169-      c !== QUIET_ZONE170-    ) return true;171-  }269+  if (r >= center - half && r <= center + half &&270+      c >= center - half && c <= center + half) return true;271+172272   return false;173273 }174274 175-// ── MATRIX BUILD ─────────────────────────────────────────────────────────────275+// ── ENCODE PIPELINE ──────────────────────────────────────────────────────────176276 177-export function encodeMayan(277+function buildHeaderAndPayload(encryptedPayload: Uint8Array): Uint8Array {278+  const header = new Uint8Array(HEADER_BYTES);279+  header[0] = MAYAN_MAGIC;280+  header[1] = MAYAN_V2;281+  const len = encryptedPayload.length;282+  header[2] = len & 0xff;283+  header[3] = (len >> 8) & 0xff;284+  header[4] = (len >> 16) & 0xff;285+  header[5] = (len >> 24) & 0xff;286+  const out = new Uint8Array(HEADER_BYTES + encryptedPayload.length);287+  out.set(header);288+  out.set(encryptedPayload, HEADER_BYTES);289+  return out;290+}291+292+/** Reed-Solomon encode + interleave. Splits `data` into RS_DATA-byte blocks293+ *  (zero-padded last block), encodes each to 255 bytes, then interleaves so294+ *  byte j of block i lands at position i + j*numBlocks. */295+function rsEncodeAndInterleave(data: Uint8Array): { interleaved: Uint8Array; numBlocks: number } {296+  const numBlocks = Math.ceil(data.length / RS_DATA);297+  const blocks: Uint8Array[] = [];298+  for (let i = 0; i < numBlocks; i++) {299+    const slice = data.subarray(i * RS_DATA, (i + 1) * RS_DATA);300+    const padded = slice.length === RS_DATA301+      ? slice302+      : (() => { const p = new Uint8Array(RS_DATA); p.set(slice); return p; })();303+    blocks.push(rsEncodeBlock(padded, RS_PARITY));304+  }305+  const interleaved = new Uint8Array(numBlocks * RS_BLOCK);306+  for (let i = 0; i < numBlocks; i++) {307+    for (let j = 0; j < RS_BLOCK; j++) {308+      interleaved[i + j * numBlocks] = blocks[i][j];309+    }310+  }311+  return { interleaved, numBlocks };312+}313+314+/** Build the 2-D module matrix without rendering to canvas. Used by315+ *  round-trip tests to skip the binarization step. */316+export function encodeMayanMatrix(178317   encryptedPayload: Uint8Array,179-  modulePx = 3,180-): MayanResult {318+): { matrix: boolean[][]; size: number } {181319   if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {182320     throw new Error(183321       `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,184322     );185323   }186324   const size = calculateSymbolSize(encryptedPayload.length);325+  if (size > MAX_SYMBOL_SIZE) {326+    throw new Error(`Mayan: symbol size ${size} exceeds max ${MAX_SYMBOL_SIZE}`);327+  }187328   const center = Math.floor(size / 2);329+  const fullData = buildHeaderAndPayload(encryptedPayload);330+  const { interleaved } = rsEncodeAndInterleave(fullData);188331 189-  // Mode header (4 bytes): magic | version | length-lo | length-hi190-  const header = new Uint8Array(4);191-  header[0] = MAYAN_MAGIC;192-  header[1] = MAYAN_VERSION;193-  header[2] = encryptedPayload.length & 0xff;194-  header[3] = (encryptedPayload.length >> 8) & 0xff;195-196-  const fullData = new Uint8Array(header.length + encryptedPayload.length);197-  fullData.set(header);198-  fullData.set(encryptedPayload, header.length);199-200-  const withECC = reedSolomonEncode(fullData);201-202332   // Bits, MSB-first per byte203-  const bits: boolean[] = [];204-  for (const byte of withECC) {205-    for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);333+  const bits: boolean[] = new Array(interleaved.length * 8);334+  for (let bi = 0; bi < interleaved.length; bi++) {335+    const byte = interleaved[bi];336+    for (let i = 0; i < 8; i++) {337+      bits[bi * 8 + i] = ((byte >> (7 - i)) & 1) === 1;338+    }206339   }207340 208341   // Build matrix@@ -211,35 +344,41 @@211344     () => new Array<boolean>(size).fill(false),212345   );213346 214-  // Reference grid (alternating dark/light in fixed phase)215-  if (size > 128) {216-    for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {217-      for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {218-        const dark = (p % 2) === 0;219-        matrix[line][p] = dark;220-        matrix[p][line] = dark;221-      }222-    }223-  }224-347+  // Structural elements first; rune drawn last so its pixels win the348+  // crossing region with the timing strips.349+  placeAnchors(matrix, size);350+  writeSizeBits(matrix, size);351+  placeTimingStrips(matrix, size);225352   drawOthalan(matrix, center, center);226353 227-  // Place data bits354+  // Place data bits in row-major scan, skipping reserved positions228355   let bitIdx = 0;229356   for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {230357     for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {231-      if (isReserved(r, c, center, size)) continue;358+      if (isReserved(r, c, size)) continue;232359       matrix[r][c] = bits[bitIdx++];233360     }234361   }235362 363+  return { matrix, size };364+}365+366+export function encodeMayan(367+  encryptedPayload: Uint8Array,368+  modulePx = 3,369+): MayanResult {370+  const { matrix, size } = encodeMayanMatrix(encryptedPayload);236371   const canvas = renderMatrix(matrix, size, modulePx);237-  const printable = size * 0.6 <= 210; // fits on A4 width at 0.6mm/module372+  const paperSize: MayanResult["paperSize"] =373+    size <= 350 ? "A4" :374+    size <= 500 ? "A3" :375+    "A3-required";238376   return {239377     canvas,240378     symbolSize: size,241379     payloadBytes: encryptedPayload.length,242-    printable,380+    printable: paperSize === "A4",381+    paperSize,243382   };244383 }245384 @@ -266,341 +405,9 @@266405   return canvas;267406 }268407 269-/** Expose the matrix in addition to the canvas — for round-trip tests that270- * skip the binarization step. */271-export function encodeMayanMatrix(272-  encryptedPayload: Uint8Array,273-): { matrix: boolean[][]; size: number } {274-  if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {275-    throw new Error(276-      `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,277-    );278-  }279-  const size = calculateSymbolSize(encryptedPayload.length);280-  const center = Math.floor(size / 2);281-  const header = new Uint8Array(4);282-  header[0] = MAYAN_MAGIC;283-  header[1] = MAYAN_VERSION;284-  header[2] = encryptedPayload.length & 0xff;285-  header[3] = (encryptedPayload.length >> 8) & 0xff;286-  const fullData = new Uint8Array(header.length + encryptedPayload.length);287-  fullData.set(header);288-  fullData.set(encryptedPayload, header.length);289-  const withECC = reedSolomonEncode(fullData);290-  const bits: boolean[] = [];291-  for (const byte of withECC) {292-    for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);293-  }294-  const matrix: boolean[][] = Array.from(295-    { length: size },296-    () => new Array<boolean>(size).fill(false),297-  );298-  if (size > 128) {299-    for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {300-      for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {301-        const dark = (p % 2) === 0;302-        matrix[line][p] = dark;303-        matrix[p][line] = dark;304-      }305-    }306-  }307-  drawOthalan(matrix, center, center);308-  let bitIdx = 0;309-  for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {310-    for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {311-      if (isReserved(r, c, center, size)) continue;312-      matrix[r][c] = bits[bitIdx++];313-    }314-  }315-  return { matrix, size };316-}408+// ── DECODE RE-EXPORTS ────────────────────────────────────────────────────────409+// The decoder lives in mayan_decode.ts so the encoder can be imported without410+// pulling in the homography / image-processing code. Callers that want the411+// historical `import { decodeMayan } from "./mayan"` ergonomics get it here.317412 318-// ── DECODER ──────────────────────────────────────────────────────────────────319-320-/** Decode a Mayan code from raw image data.321- * Returns null when the image isn't a Mayan code (no rune found, magic byte322- * mismatch, or unknown version) or when RS correction fails. */323-export function decodeMayan(imageData: ImageData): Uint8Array | null {324-  const binary = adaptiveBinarize(imageData);325-  const W = imageData.width;326-  const H = imageData.height;327-328-  const rune = findOthalan(binary, W, H);329-  if (!rune) return null;330-331-  const modulePx = estimateModuleSize(binary, rune, W);332-  if (!modulePx || modulePx < 1) return null;333-334-  const symbolModules = Math.round(Math.min(W, H) / modulePx);335-  if (symbolModules < 64) return null;336-337-  const rotation = detectRotation(binary, rune, modulePx);338-  const matrix = sampleModuleGrid(339-    binary,340-    { x: rune.cx, y: rune.cy },341-    modulePx,342-    symbolModules,343-    rotation,344-  );345-346-  return decodeMatrix(matrix, symbolModules);347-}348-349-/** Decode a Mayan code from a sampled module matrix (for tests). */350-export function decodeMatrix(351-  matrix: boolean[][],352-  symbolModules: number,353-): Uint8Array | null {354-  const center = Math.floor(symbolModules / 2);355-356-  // Read the first 32 data bits to recover the mode header357-  const headerBits = readDataBits(matrix, center, symbolModules, 32);358-  if (headerBits.length < 32) return null;359-  const headerBytes = bitsToBytes(headerBits);360-  if (headerBytes[0] !== MAYAN_MAGIC) return null;361-  if (headerBytes[1] !== MAYAN_VERSION) return null;362-  const payloadLength = headerBytes[2] | (headerBytes[3] << 8);363-  if (payloadLength > MAX_PAYLOAD_BYTES) return null;364-365-  // Read all data bits — round up to whole RS(255,178) blocks366-  const totalDataBytes = 4 + payloadLength;367-  const blocks = Math.ceil(totalDataBytes / 178);368-  const totalBits = blocks * 255 * 8;369-  const allBits = readDataBits(matrix, center, symbolModules, totalBits);370-  if (allBits.length < totalBits) return null;371-  const rawBytes = new Uint8Array(bitsToBytes(allBits));372-373-  let decoded: Uint8Array;374-  try {375-    decoded = reedSolomonDecode(rawBytes, totalDataBytes);376-  } catch {377-    return null;378-  }379-  return decoded.slice(4, 4 + payloadLength);380-}381-382-// ── BINARIZATION ─────────────────────────────────────────────────────────────383-384-function adaptiveBinarize(imageData: ImageData): boolean[][] {385-  const { data, width, height } = imageData;386-  const out: boolean[][] = Array.from(387-    { length: height },388-    () => new Array<boolean>(width).fill(false),389-  );390-  const R = 9;391-  // Integral image of grayscale for O(1) box mean per pixel.392-  const integral = new Float64Array((width + 1) * (height + 1));393-  for (let y = 0; y < height; y++) {394-    let rowSum = 0;395-    for (let x = 0; x < width; x++) {396-      const i = (y * width + x) * 4;397-      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];398-      rowSum += gray;399-      integral[(y + 1) * (width + 1) + (x + 1)] =400-        integral[y * (width + 1) + (x + 1)] + rowSum;401-    }402-  }403-  for (let y = 0; y < height; y++) {404-    for (let x = 0; x < width; x++) {405-      const i = (y * width + x) * 4;406-      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];407-      const x0 = Math.max(0, x - R);408-      const y0 = Math.max(0, y - R);409-      const x1 = Math.min(width - 1, x + R);410-      const y1 = Math.min(height - 1, y + R);411-      const sum =412-        integral[(y1 + 1) * (width + 1) + (x1 + 1)] -413-        integral[(y0) * (width + 1) + (x1 + 1)] -414-        integral[(y1 + 1) * (width + 1) + (x0)] +415-        integral[(y0) * (width + 1) + (x0)];416-      const count = (x1 - x0 + 1) * (y1 - y0 + 1);417-      out[y][x] = gray < sum / count - 8;418-    }419-  }420-  return out;421-}422-423-// ── RUNE LOCATION ───────────────────────────────────────────────────────────424-425-function findOthalan(426-  binary: boolean[][],427-  W: number,428-  H: number,429-): { cx: number; cy: number; size: number } | null {430-  // Scan horizontally for a dark-light-dark pattern with the diamond's431-  // expected proportions: each side ~4-5 modules wide, light interior ≥1.5×.432-  //433-  // Sliding-window: each dark blob is considered as a potential darkStart.434-  // We peek light + dark2 ahead but only consume dark1 — so on a failed435-  // match the next iteration's darkStart slides forward to dark2's position436-  // (instead of leaping past it). This is critical because the data area437-  // immediately left of the rune can contain dark blobs that would otherwise438-  // eat the rune's left vertex on a non-sliding scan.439-  const candidates: { cx: number; cy: number; size: number }[] = [];440-441-  const yStart = Math.floor(H * 0.2);442-  const yEnd = Math.floor(H * 0.8);443-  for (let y = yStart; y < yEnd; y += 2) {444-    let x = 0;445-    while (x < W) {446-      while (x < W && !binary[y][x]) x++;447-      if (x >= W) break;448-      const darkStart = x;449-      while (x < W && binary[y][x]) x++;450-      const darkLen = x - darkStart;451-452-      // Peek ahead: light run, then next dark run. Don't consume them — the453-      // next loop iteration will re-skip the light and treat dark2 as dark1.454-      let p = x;455-      while (p < W && !binary[y][p]) p++;456-      const lightLen = p - x;457-      const dark2Start = p;458-      while (p < W && binary[y][p]) p++;459-      const dark2Len = p - dark2Start;460-461-      if (462-        darkLen >= 4 &&463-        dark2Len >= 4 &&464-        Math.abs(darkLen - dark2Len) <= darkLen * 0.3 &&465-        lightLen > darkLen * 1.5 &&466-        lightLen < darkLen * 12467-      ) {468-        const cx = Math.floor((darkStart + p) / 2);469-        const size = p - darkStart;470-        candidates.push({ cx, cy: y, size });471-      }472-      // x stays at end of dark1; loop's skip-light advances us into dark2.473-    }474-  }475-476-  if (candidates.length < 3) return null;477-478-  // The rune diamond's three widest scan rows (rune-rows 9, 10, 11) all match479-  // the dark-light-dark pattern with size = 23 modules. Pick all candidates480-  // tied within ~1 module of the global max, then average their cy/cx to481-  // recover the diamond's geometric center robustly.482-  const maxSize = candidates.reduce((m, c) => Math.max(m, c.size), 0);483-  const modulePx = Math.max(1, Math.round(maxSize / 23));484-  const top = candidates.filter((c) => c.size >= maxSize - modulePx);485-  let sumCx = 0;486-  let sumCy = 0;487-  for (const c of top) { sumCx += c.cx; sumCy += c.cy; }488-  const cxAvg = sumCx / top.length;489-  const cyAvg = sumCy / top.length;490-491-  // The averaged cy lands on the diamond's geometric vertical center492-  // (rune-row 10, the LEFT/RIGHT vertex row). The code-grid center is at493-  // rune-row 11 — exactly one module below — because drawOthalan offsets494-  // the diamond up by one to make room for the legs. Shift down 1 module.495-  return {496-    cx: Math.round(cxAvg),497-    cy: Math.round(cyAvg + modulePx),498-    size: maxSize,499-  };500-}501-502-function estimateModuleSize(503-  _binary: boolean[][],504-  rune: { cx: number; cy: number; size: number },505-  _W: number,506-): number {507-  // Rune spans ~23 modules at its widest horizontal cross-section.508-  // Round to integer: module boundaries are always integer pixels.509-  return Math.round(rune.size / 23);510-}511-512-function detectRotation(513-  binary: boolean[][],514-  rune: { cx: number; cy: number; size: number },515-  modulePx: number,516-): number {517-  // Orientation marker is a 3×3 dark block at (rune-row 1-3, rune-col 20-22)518-  // = 10 modules right and 10 modules above center at 0°.519-  const offsets: { angle: number; dr: number; dc: number }[] = [520-    { angle: 0,   dr: -10, dc: +10 },521-    { angle: 90,  dr: +10, dc: +10 },522-    { angle: 180, dr: +10, dc: -10 },523-    { angle: 270, dr: -10, dc: -10 },524-  ];525-  let bestAngle = 0;526-  let bestScore = -1;527-  for (const { angle, dr, dc } of offsets) {528-    const pr = Math.round(rune.cy + dr * modulePx);529-    const pc = Math.round(rune.cx + dc * modulePx);530-    let darkCount = 0;531-    for (let dr2 = -1; dr2 <= 1; dr2++) {532-      for (let dc2 = -1; dc2 <= 1; dc2++) {533-        const r = pr + Math.round(dr2 * modulePx);534-        const c = pc + Math.round(dc2 * modulePx);535-        if (r >= 0 && r < binary.length && c >= 0 && c < binary[0].length) {536-          if (binary[r][c]) darkCount++;537-        }538-      }539-    }540-    if (darkCount > bestScore) {541-      bestScore = darkCount;542-      bestAngle = angle;543-    }544-  }545-  return bestAngle;546-}547-548-function sampleModuleGrid(549-  binary: boolean[][],550-  center: { x: number; y: number },551-  modulePx: number,552-  symbolModules: number,553-  rotation: number,554-): boolean[][] {555-  const half = Math.floor(symbolModules / 2);556-  const matrix: boolean[][] = Array.from(557-    { length: symbolModules },558-    () => new Array<boolean>(symbolModules).fill(false),559-  );560-  for (let r = 0; r < symbolModules; r++) {561-    for (let c = 0; c < symbolModules; c++) {562-      let dr = r - half;563-      let dc = c - half;564-      if (rotation === 90)  { [dr, dc] = [dc, -dr]; }565-      if (rotation === 180) { [dr, dc] = [-dr, -dc]; }566-      if (rotation === 270) { [dr, dc] = [-dc, dr]; }567-      const px = Math.round(center.x + dc * modulePx);568-      const py = Math.round(center.y + dr * modulePx);569-      if (570-        py >= 0 &&571-        py < binary.length &&572-        px >= 0 &&573-        px < binary[0].length574-      ) matrix[r][c] = binary[py][px];575-    }576-  }577-  return matrix;578-}579-580-function readDataBits(581-  matrix: boolean[][],582-  center: number,583-  size: number,584-  count: number,585-): boolean[] {586-  const bits: boolean[] = [];587-  for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {588-    for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {589-      if (isReserved(r, c, center, size)) continue;590-      bits.push(matrix[r][c]);591-    }592-  }593-  return bits;594-}595-596-function bitsToBytes(bits: boolean[]): number[] {597-  const bytes: number[] = [];598-  for (let i = 0; i < bits.length; i += 8) {599-    let byte = 0;600-    for (let j = 0; j < 8 && i + j < bits.length; j++) {601-      if (bits[i + j]) byte |= 1 << (7 - j);602-    }603-    bytes.push(byte);604-  }605-  return bytes;606-}413+export { decodeMayan, decodeMatrix } from "./mayan_decode";

view file

src/crypto/mayan_decode.ts+659 −0
@@ -0,0 +1,659 @@1+/**2+ * Mayan v2 decoder — anchor-finder + homography pipeline.3+ *4+ * Pipeline:5+ *   binarize → find 3 corner anchors via 1:1:3:1:1 run-length pattern →6+ *   classify TL/TR/BL by quadrant → compute DLT homography (pixel↔module)7+ *   from 4 control points (TL, TR, BL, extrapolated BR) → sample grid →8+ *   read 16-bit size marker → re-sample at exact size if different →9+ *   probe `numBlocks` from the interleaved header → RS-decode each block →10+ *   strip header, return encrypted payload.11+ */12+13+import { rsDecodeBlock } from "./rs";14+import {15+  MAYAN_MAGIC,16+  MAYAN_V2,17+  RS_DATA,18+  RS_PARITY,19+  RS_BLOCK,20+  HEADER_BYTES,21+  QUIET_ZONE,22+  ANCHOR_FOOTPRINT,23+  MIN_SYMBOL_SIZE,24+  MAX_SYMBOL_SIZE,25+  isReserved,26+} from "./mayan";27+28+// ── PUBLIC ENTRY POINTS ──────────────────────────────────────────────────────29+30+/** Decode a Mayan code (v1 or v2) from raw image data. Returns the31+ *  encrypted-and-compressed payload byte stream — caller is responsible32+ *  for AES-GCM decrypt and zstd decompress (or, in Peach, parsePayload).33+ *34+ *  Returns null when no Mayan code is found, magic byte mismatches, or35+ *  Reed-Solomon correction fails. */36+export function decodeMayan(imageData: ImageData): Uint8Array | null {37+  const binary = adaptiveBinarize(imageData);38+  const W = imageData.width;39+  const H = imageData.height;40+41+  const anchors = findAnchors(binary, W, H);42+  if (anchors.length < 3) return null;43+44+  const orient = classifyAnchors(anchors);45+  if (!orient) return null;46+  const { TL, TR, BL } = orient;47+  const modulePx = orient.modulePx;48+  if (modulePx < 1) return null;49+50+  // Estimate symbol size from anchor spacing. Anchor inner-3×3 centers are51+  // at module (q+4, q+4) for TL — so the distance TL→TR in modules is52+  // (size - 2q - 9) and we recover size = round(distance/modulePx) + 2q + 9.53+  const distH = Math.hypot(TR.x - TL.x, TR.y - TL.y);54+  const distV = Math.hypot(BL.x - TL.x, BL.y - TL.y);55+  const span = (distH + distV) / 2 / modulePx;56+  let estSize = Math.round(span) + 2 * QUIET_ZONE + ANCHOR_FOOTPRINT;57+  estSize = clamp(estSize, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE);58+59+  const matrix = sampleAndReadExact(binary, TL, TR, BL, estSize);60+  if (!matrix) return null;61+62+  return decodeMatrix(matrix.matrix, matrix.size);63+}64+65+/** Decode a Mayan v2 module matrix directly. Used by tests that bypass66+ *  binarization and homography. */67+export function decodeMatrix(68+  matrix: boolean[][],69+  symbolSize: number,70+): Uint8Array | null {71+  // Read every available data bit. The number of blocks is unknown until72+  // we parse the header, but the header is itself interleaved across73+  // blocks — so we read everything, probe `numBlocks` against the header,74+  // then RS-decode at the right blocking.75+  const allBits = readDataBits(matrix, symbolSize, Infinity);76+  const interleaved = new Uint8Array(bitsToBytes(allBits));77+78+  if (interleaved.length === 0) return null;79+  const maxK = Math.floor(interleaved.length / RS_BLOCK);80+  // Magic byte sits at interleaved[0] regardless of numBlocks (block 0,81+  // byte 0 → position 0). Skip the fast probe if it's already wrong; the82+  // slow probe still gets a chance to RS-correct a flipped magic byte.83+  // Fast probe first: for each candidate k, the header bytes live at84+  // positions [0, k, 2k, 3k, 4k, 5k]. Check version + length consistency.85+  for (let k = 1; interleaved[0] === MAYAN_MAGIC && k <= maxK; k++) {86+    if (5 * k >= interleaved.length) break;87+    const version = interleaved[k];88+    if (version !== MAYAN_V2) continue;89+    const length = interleaved[2 * k]90+      | (interleaved[3 * k] << 8)91+      | (interleaved[4 * k] << 16)92+      | (interleaved[5 * k] << 24);93+    if (length < 0 || length > 100_000_000) continue;94+    if (Math.ceil((length + HEADER_BYTES) / RS_DATA) !== k) continue;95+    const decoded = rsDecodeAtBlocks(interleaved, k);96+    if (!decoded) continue;97+    // Re-validate header from the corrected stream (in case the fast98+    // probe accepted on un-corrected bytes that happened to be valid).99+    if (decoded[0] !== MAYAN_MAGIC || decoded[1] !== MAYAN_V2) continue;100+    const len2 = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);101+    if (len2 < 0 || len2 + HEADER_BYTES > decoded.length) continue;102+    return decoded.slice(HEADER_BYTES, HEADER_BYTES + len2);103+  }104+105+  // Slow probe: header may be corrupted. RS-decode block 0 at every106+  // candidate k and check the corrected header.107+  for (let k = 1; k <= maxK; k++) {108+    const decoded = rsDecodeAtBlocks(interleaved, k);109+    if (!decoded) continue;110+    if (decoded[0] !== MAYAN_MAGIC) continue;111+    if (decoded[1] !== MAYAN_V2) continue;112+    const len = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);113+    if (len < 0 || len + HEADER_BYTES > decoded.length) continue;114+    if (Math.ceil((len + HEADER_BYTES) / RS_DATA) !== k) continue;115+    return decoded.slice(HEADER_BYTES, HEADER_BYTES + len);116+  }117+118+  return null;119+}120+121+/** Deinterleave the first `numBlocks * 255` bytes and RS-decode each block.122+ *  Returns the concatenated `numBlocks * 204`-byte data payload (with123+ *  header still prepended), or null if any block is uncorrectable. */124+function rsDecodeAtBlocks(125+  interleaved: Uint8Array,126+  numBlocks: number,127+): Uint8Array | null {128+  if (interleaved.length < numBlocks * RS_BLOCK) return null;129+  const out = new Uint8Array(numBlocks * RS_DATA);130+  const block = new Uint8Array(RS_BLOCK);131+  for (let i = 0; i < numBlocks; i++) {132+    for (let j = 0; j < RS_BLOCK; j++) {133+      block[j] = interleaved[i + j * numBlocks];134+    }135+    const corrected = rsDecodeBlock(block, RS_PARITY);136+    if (!corrected) return null;137+    out.set(corrected, i * RS_DATA);138+  }139+  return out;140+}141+142+// ── BINARIZATION ─────────────────────────────────────────────────────────────143+144+function adaptiveBinarize(imageData: ImageData): boolean[][] {145+  const { data, width, height } = imageData;146+  const out: boolean[][] = Array.from(147+    { length: height },148+    () => new Array<boolean>(width).fill(false),149+  );150+  const R = 9;151+  const integral = new Float64Array((width + 1) * (height + 1));152+  for (let y = 0; y < height; y++) {153+    let rowSum = 0;154+    for (let x = 0; x < width; x++) {155+      const i = (y * width + x) * 4;156+      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];157+      rowSum += gray;158+      integral[(y + 1) * (width + 1) + (x + 1)] =159+        integral[y * (width + 1) + (x + 1)] + rowSum;160+    }161+  }162+  for (let y = 0; y < height; y++) {163+    for (let x = 0; x < width; x++) {164+      const i = (y * width + x) * 4;165+      const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];166+      const x0 = Math.max(0, x - R);167+      const y0 = Math.max(0, y - R);168+      const x1 = Math.min(width - 1, x + R);169+      const y1 = Math.min(height - 1, y + R);170+      const sum =171+        integral[(y1 + 1) * (width + 1) + (x1 + 1)] -172+        integral[(y0) * (width + 1) + (x1 + 1)] -173+        integral[(y1 + 1) * (width + 1) + (x0)] +174+        integral[(y0) * (width + 1) + (x0)];175+      const count = (x1 - x0 + 1) * (y1 - y0 + 1);176+      out[y][x] = gray < sum / count - 8;177+    }178+  }179+  return out;180+}181+182+// ── ANCHOR DETECTION ─────────────────────────────────────────────────────────183+184+interface AnchorMatch {185+  cx: number;186+  cy: number;187+  modulePx: number;188+}189+190+interface RawCandidate {191+  cx: number;192+  cy: number;193+  modulePx: number;194+}195+196+/** Scan rows for the 1:1:3:1:1 light-dark-light-dark-light pattern that197+ *  characterises the corner anchor (outer dark frame, light gap, inner198+ *  3-module dark block, light gap, outer dark frame). Confirm vertically199+ *  at the same column, then verify the candidate's center 3×3-module block200+ *  is solidly dark. Returns clustered, deduplicated anchor centers. */201+function findAnchors(binary: boolean[][], W: number, H: number): AnchorMatch[] {202+  const candidates: RawCandidate[] = [];203+204+  for (let y = 0; y < H; y += 2) {205+    scanLineForAnchor(206+      (i) => binary[y]?.[i] ?? false,207+      W,208+      (cxRel, modulePx) => candidates.push({ cx: cxRel, cy: y, modulePx }),209+    );210+  }211+212+  const confirmed: AnchorMatch[] = [];213+  for (const c of candidates) {214+    if (c.cx < 0 || c.cx >= W) continue;215+    const hits: { cy: number; modulePx: number }[] = [];216+    scanLineForAnchor(217+      (i) => binary[i]?.[c.cx] ?? false,218+      H,219+      (cyRel, modulePx) => hits.push({ cy: cyRel, modulePx }),220+    );221+    let best: { cy: number; modulePx: number } | null = null;222+    for (const h of hits) {223+      if (Math.abs(h.cy - c.cy) > c.modulePx * 5) continue;224+      if (!best || Math.abs(h.cy - c.cy) < Math.abs(best.cy - c.cy)) best = h;225+    }226+    if (!best) continue;227+    const cy = Math.round((c.cy + best.cy) / 2);228+    const modulePx = (c.modulePx + best.modulePx) / 2;229+    if (!verifyAnchorCenter(binary, c.cx, cy, modulePx, W, H)) continue;230+    confirmed.push({ cx: c.cx, cy, modulePx });231+  }232+233+  return clusterAnchors(confirmed);234+}235+236+/** Verify that the candidate's central 3×3-module block is solidly dark237+ *  (≥80% of sampled pixels). The horizontal and vertical 1:1:3:1:1 scans238+ *  alone match many false positives in random data — but the real anchor's239+ *  central 3×3 must be a uniform dark block. This kills the false positives240+ *  cheaply. */241+function verifyAnchorCenter(242+  binary: boolean[][],243+  cx: number, cy: number,244+  modulePx: number,245+  W: number, H: number,246+): boolean {247+  // Sample a 3×3 grid of points spanning the central 3 modules248+  const step = modulePx;249+  let dark = 0, total = 0;250+  for (let dr = -1; dr <= 1; dr++) {251+    for (let dc = -1; dc <= 1; dc++) {252+      const x = Math.round(cx + dc * step);253+      const y = Math.round(cy + dr * step);254+      if (x < 0 || x >= W || y < 0 || y >= H) return false;255+      total++;256+      if (binary[y][x]) dark++;257+    }258+  }259+  if (dark < total * 0.8) return false;260+261+  // And verify the immediate ring outside (at ±2 modules) has at least262+  // some light points — the real anchor has a light gap there.263+  let light = 0, ringTotal = 0;264+  for (let dr = -2; dr <= 2; dr++) {265+    for (let dc = -2; dc <= 2; dc++) {266+      if (Math.abs(dr) < 2 && Math.abs(dc) < 2) continue; // only the ring267+      const x = Math.round(cx + dc * step);268+      const y = Math.round(cy + dr * step);269+      if (x < 0 || x >= W || y < 0 || y >= H) continue;270+      ringTotal++;271+      if (!binary[y][x]) light++;272+    }273+  }274+  return ringTotal > 0 && light >= ringTotal * 0.5;275+}276+277+/** Scan a single 1-D line via a `pixel(i)` accessor for the 1:1:3:1:1278+ *  pattern. Calls `onMatch(centerPosition, modulePx)` for every match. */279+function scanLineForAnchor(280+  pixel: (i: number) => boolean,281+  N: number,282+  onMatch: (center: number, modulePx: number) => void,283+): void {284+  // Sliding-window run-length scan: maintain a rolling history of the last285+  // 5 alternating runs (light, dark, light, dark, light). On each new run,286+  // evaluate the trailing 5-run window for the 1:1:3:1:1 ratio.287+  const runs: { len: number; dark: boolean; start: number }[] = [];288+  let i = 0;289+  let cur = pixel(0);290+  let runStart = 0;291+  while (i <= N) {292+    const px = i < N ? pixel(i) : !cur;293+    if (px !== cur || i === N) {294+      runs.push({ len: i - runStart, dark: cur, start: runStart });295+      runStart = i;296+      cur = px;297+      // Keep only last 5298+      if (runs.length > 5) runs.shift();299+      if (runs.length === 5) {300+        const [r0, r1, r2, r3, r4] = runs;301+        // We need light, dark, light, dark, light302+        if (!r0.dark && r1.dark && !r2.dark && r3.dark && !r4.dark) {303+          const total = r0.len + r1.len + r2.len + r3.len + r4.len;304+          const unit = total / 7;305+          if (unit >= 1 &&306+              within(r0.len, unit, 0.5) &&307+              within(r1.len, unit, 0.5) &&308+              within(r2.len, unit * 3, 0.8) &&309+              within(r3.len, unit, 0.5) &&310+              within(r4.len, unit, 0.5)) {311+            const center = r2.start + r2.len / 2;312+            onMatch(Math.round(center), unit);313+          }314+        }315+        // Also accept the case where the line starts inside a dark region:316+        // dark, light, dark, light, dark — with the middle dark being the317+        // 3-unit center block.318+        if (r0.dark && !r1.dark && r2.dark && !r3.dark && r4.dark) {319+          const total = r0.len + r1.len + r2.len + r3.len + r4.len;320+          const unit = total / 7;321+          if (unit >= 1 &&322+              within(r0.len, unit, 0.5) &&323+              within(r1.len, unit, 0.5) &&324+              within(r2.len, unit * 3, 0.8) &&325+              within(r3.len, unit, 0.5) &&326+              within(r4.len, unit, 0.5)) {327+            const center = r2.start + r2.len / 2;328+            onMatch(Math.round(center), unit);329+          }330+        }331+      }332+    }333+    i++;334+  }335+}336+337+function within(value: number, target: number, tolerance: number): boolean {338+  return Math.abs(value - target) <= target * tolerance;339+}340+341+/** Cluster nearby anchor matches into single representatives. Two matches342+ *  are the same anchor if they are within ~2 modules of each other. */343+function clusterAnchors(matches: AnchorMatch[]): AnchorMatch[] {344+  const clusters: AnchorMatch[][] = [];345+  for (const m of matches) {346+    let placed = false;347+    for (const cluster of clusters) {348+      const ref = cluster[0];349+      const distSq = (m.cx - ref.cx) ** 2 + (m.cy - ref.cy) ** 2;350+      if (distSq <= (ref.modulePx * 3) ** 2) {351+        cluster.push(m);352+        placed = true;353+        break;354+      }355+    }356+    if (!placed) clusters.push([m]);357+  }358+  return clusters.map((cluster) => {359+    let sx = 0, sy = 0, sm = 0;360+    for (const c of cluster) { sx += c.cx; sy += c.cy; sm += c.modulePx; }361+    return {362+      cx: sx / cluster.length,363+      cy: sy / cluster.length,364+      modulePx: sm / cluster.length,365+    };366+  });367+}368+369+// ── ORIENTATION CLASSIFICATION ───────────────────────────────────────────────370+371+interface Orientation {372+  TL: Point;373+  TR: Point;374+  BL: Point;375+  modulePx: number;376+}377+378+interface Point { x: number; y: number }379+380+/** Pick the three anchors corresponding to the genuine corner squares.381+ *382+ *  Strategy: the real anchors share the same modulePx (they're the same383+ *  physical pattern photographed in the same image). False positives — the384+ *  1:1:3:1:1 run-length artefacts that random data sometimes produces —385+ *  scatter across modulePx values. Cluster candidates by modulePx, keep386+ *  the dominant bucket, then among that bucket pick the triplet that best387+ *  matches a right-angle isoceles triangle with equal legs (TL→TR ≈ TL→BL,388+ *  angle at TL ≈ 90°). */389+function classifyAnchors(anchors: AnchorMatch[]): Orientation | null {390+  if (anchors.length < 3) return null;391+392+  let best: {393+    TL: AnchorMatch; TR: AnchorMatch; BL: AnchorMatch; score: number;394+  } | null = null;395+396+  const N = anchors.length;397+  for (let i = 0; i < N; i++) {398+    for (let j = 0; j < N; j++) {399+      if (j === i) continue;400+      for (let k = 0; k < N; k++) {401+        if (k === i || k === j) continue;402+        const tl = anchors[i];403+        const a = anchors[j];404+        const b = anchors[k];405+        const va = { x: a.cx - tl.cx, y: a.cy - tl.cy };406+        const vb = { x: b.cx - tl.cx, y: b.cy - tl.cy };407+        const lenA = Math.hypot(va.x, va.y);408+        const lenB = Math.hypot(vb.x, vb.y);409+        if (lenA < 1 || lenB < 1) continue;410+        // Order: TR has positive cross product (rightward then downward in411+        // y-down image coords).412+        const cross = va.x * vb.y - va.y * vb.x;413+        if (cross <= 0) continue;414+        // Right-angle at TL415+        const cos = (va.x * vb.x + va.y * vb.y) / (lenA * lenB);416+        const angularScore = -Math.abs(cos);417+        // Equal legs418+        const legRatio = Math.min(lenA, lenB) / Math.max(lenA, lenB);419+        const lengthScore = -Math.abs(1 - legRatio);420+        // Module size consistency — real anchors share one modulePx.421+        const meanM = (tl.modulePx + a.modulePx + b.modulePx) / 3;422+        const varM = (423+          (tl.modulePx - meanM) ** 2 +424+          (a.modulePx - meanM) ** 2 +425+          (b.modulePx - meanM) ** 2426+        ) / 3;427+        const stdM = Math.sqrt(varM);428+        const moduleScore = -stdM / Math.max(meanM, 1);429+        // Triangle leg ≈ symbol pixel span. Real anchors are far apart.430+        const sizeScore = Math.log(lenA + lenB) * 0.1;431+        const score =432+          angularScore * 5 +433+          lengthScore * 5 +434+          moduleScore * 8 +435+          sizeScore;436+        if (!best || score > best.score) {437+          best = { TL: tl, TR: a, BL: b, score };438+        }439+      }440+    }441+  }442+  if (!best) return null;443+  const modulePx = (best.TL.modulePx + best.TR.modulePx + best.BL.modulePx) / 3;444+  return {445+    TL: { x: best.TL.cx, y: best.TL.cy },446+    TR: { x: best.TR.cx, y: best.TR.cy },447+    BL: { x: best.BL.cx, y: best.BL.cy },448+    modulePx,449+  };450+}451+452+// ── HOMOGRAPHY (DLT, 4 point pairs) ──────────────────────────────────────────453+454+/** Solve a 3×3 homography H mapping src points (sx, sy) to dst points455+ *  (dx, dy) such that `dst = H * src` in homogeneous coords. Uses DLT with456+ *  h22 fixed at 1 — sufficient for non-degenerate quadrilaterals.457+ *  Returns the flat 9-element matrix [h00..h22] or null on singular input. */458+function computeHomography(src: Point[], dst: Point[]): number[] | null {459+  if (src.length !== 4 || dst.length !== 4) return null;460+  // 8 unknowns: h00,h01,h02,h10,h11,h12,h20,h21461+  // Per pair: dx = h00*sx + h01*sy + h02 - h20*sx*dx - h21*sy*dx462+  //           dy = h10*sx + h11*sy + h12 - h20*sx*dy - h21*sy*dy463+  const A: number[][] = [];464+  const b: number[] = [];465+  for (let i = 0; i < 4; i++) {466+    const sx = src[i].x, sy = src[i].y;467+    const dx = dst[i].x, dy = dst[i].y;468+    A.push([sx, sy, 1, 0, 0, 0, -sx * dx, -sy * dx]);469+    b.push(dx);470+    A.push([0, 0, 0, sx, sy, 1, -sx * dy, -sy * dy]);471+    b.push(dy);472+  }473+  const x = solve8(A, b);474+  if (!x) return null;475+  return [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1];476+}477+478+/** Solve an 8×8 linear system via partial-pivot Gaussian elimination. */479+function solve8(A: number[][], b: number[]): number[] | null {480+  const n = 8;481+  const M: number[][] = A.map((row, i) => [...row, b[i]]);482+  for (let col = 0; col < n; col++) {483+    let pivot = col;484+    for (let r = col + 1; r < n; r++) {485+      if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;486+    }487+    if (Math.abs(M[pivot][col]) < 1e-12) return null;488+    [M[col], M[pivot]] = [M[pivot], M[col]];489+    for (let r = 0; r < n; r++) {490+      if (r === col) continue;491+      const factor = M[r][col] / M[col][col];492+      if (factor === 0) continue;493+      for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];494+    }495+  }496+  const x: number[] = new Array(n);497+  for (let i = 0; i < n; i++) x[i] = M[i][n] / M[i][i];498+  return x;499+}500+501+function applyHomography(H: number[], px: number, py: number): Point {502+  const w = H[6] * px + H[7] * py + H[8];503+  return {504+    x: (H[0] * px + H[1] * py + H[2]) / w,505+    y: (H[3] * px + H[4] * py + H[5]) / w,506+  };507+}508+509+function invertHomography(H: number[]): number[] | null {510+  const a = H[0], b = H[1], c = H[2];511+  const d = H[3], e = H[4], f = H[5];512+  const g = H[6], h = H[7], i = H[8];513+  const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);514+  if (Math.abs(det) < 1e-12) return null;515+  const inv = 1 / det;516+  return [517+    (e * i - f * h) * inv,518+    (c * h - b * i) * inv,519+    (b * f - c * e) * inv,520+    (f * g - d * i) * inv,521+    (a * i - c * g) * inv,522+    (c * d - a * f) * inv,523+    (d * h - e * g) * inv,524+    (b * g - a * h) * inv,525+    (a * e - b * d) * inv,526+  ];527+}528+529+// ── GRID SAMPLING ────────────────────────────────────────────────────────────530+531+/** Anchor inner-3×3-block module coordinates for a given symbol size.532+ *  These are the *center* coordinates (module index + 0.5) used as the533+ *  destination side of the homography. */534+function anchorModuleCenter(size: number, corner: "TL" | "TR" | "BL" | "BR"): Point {535+  const q = QUIET_ZONE;536+  // Inner 3×3 dark block centers at module (q+4, q+4) for TL etc. Add 0.5537+  // for module-center coordinates.538+  const tlx = q + 4 + 0.5;539+  const tly = q + 4 + 0.5;540+  const farx = size - q - 5 + 0.5; // size - q - ANCHOR_FOOTPRINT + 4 + 0.5541+  const fary = size - q - 5 + 0.5;542+  switch (corner) {543+    case "TL": return { x: tlx, y: tly };544+    case "TR": return { x: farx, y: tly };545+    case "BL": return { x: tlx, y: fary };546+    case "BR": return { x: farx, y: fary };547+  }548+}549+550+/** Sample the binary image at the ideal module grid for a given symbol551+ *  size, using a homography fitted from the three known anchors plus an552+ *  extrapolated BR. */553+function sampleGrid(554+  binary: boolean[][],555+  TL: Point, TR: Point, BL: Point,556+  size: number,557+): boolean[][] | null {558+  // BR ≈ TL + (TR - TL) + (BL - TL)559+  const BR: Point = {560+    x: TL.x + (TR.x - TL.x) + (BL.x - TL.x),561+    y: TL.y + (TR.y - TL.y) + (BL.y - TL.y),562+  };563+  // src = pixel coords of anchors; dst = module-center coords.564+  const src = [TL, TR, BL, BR];565+  const dst = [566+    anchorModuleCenter(size, "TL"),567+    anchorModuleCenter(size, "TR"),568+    anchorModuleCenter(size, "BL"),569+    anchorModuleCenter(size, "BR"),570+  ];571+  // We need module → pixel sampling; build H_inv directly by swapping src/dst.572+  const Hinv = computeHomography(dst, src);573+  if (!Hinv) return null;574+575+  const matrix: boolean[][] = Array.from(576+    { length: size },577+    () => new Array<boolean>(size).fill(false),578+  );579+  const H = binary.length;580+  const W = binary[0]?.length ?? 0;581+  for (let r = 0; r < size; r++) {582+    for (let c = 0; c < size; c++) {583+      const p = applyHomography(Hinv, c + 0.5, r + 0.5);584+      const ix = Math.round(p.x);585+      const iy = Math.round(p.y);586+      if (iy >= 0 && iy < H && ix >= 0 && ix < W) {587+        matrix[r][c] = binary[iy][ix];588+      }589+    }590+  }591+  return matrix;592+}593+594+/** Two-pass sampling: read the size bits at the estimated size, then595+ *  re-sample at the exact size if they differ. */596+function sampleAndReadExact(597+  binary: boolean[][],598+  TL: Point, TR: Point, BL: Point,599+  estSize: number,600+): { matrix: boolean[][]; size: number } | null {601+  const rough = sampleGrid(binary, TL, TR, BL, estSize);602+  if (!rough) return null;603+  const exact = readSizeBits(rough);604+  if (exact >= MIN_SYMBOL_SIZE && exact <= MAX_SYMBOL_SIZE && exact !== estSize) {605+    const final = sampleGrid(binary, TL, TR, BL, exact);606+    if (!final) return null;607+    return { matrix: final, size: exact };608+  }609+  if (exact < MIN_SYMBOL_SIZE || exact > MAX_SYMBOL_SIZE) {610+    // Fall back to the estimated size — RS may still recover.611+    return { matrix: rough, size: estSize };612+  }613+  return { matrix: rough, size: exact };614+}615+616+function readSizeBits(matrix: boolean[][]): number {617+  const row = QUIET_ZONE;618+  const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;619+  let val = 0;620+  for (let i = 0; i < 16; i++) {621+    if (matrix[row]?.[colStart + i]) val |= 1 << (15 - i);622+  }623+  return val;624+}625+626+// ── BIT / BYTE HELPERS ───────────────────────────────────────────────────────627+628+function readDataBits(629+  matrix: boolean[][],630+  size: number,631+  count: number,632+): boolean[] {633+  const bits: boolean[] = [];634+  for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {635+    const row = matrix[r];636+    if (!row) continue;637+    for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {638+      if (isReserved(r, c, size)) continue;639+      bits.push(row[c] ?? false);640+    }641+  }642+  return bits;643+}644+645+function bitsToBytes(bits: boolean[]): number[] {646+  const bytes: number[] = [];647+  for (let i = 0; i < bits.length; i += 8) {648+    let byte = 0;649+    for (let j = 0; j < 8 && i + j < bits.length; j++) {650+      if (bits[i + j]) byte |= 1 << (7 - j);651+    }652+    bytes.push(byte);653+  }654+  return bytes;655+}656+657+function clamp(v: number, lo: number, hi: number): number {658+  return Math.max(lo, Math.min(hi, v));659+}

view file

src/crypto/rs.ts+27 −15
@@ -2,23 +2,15 @@22  * Reed-Solomon codec — GF(2^8) with primitive polynomial 0x11D33  * (x^8 + x^4 + x^3 + x^2 + 1, the standard CCSDS / NASA polynomial).44  *5- * Block-based RS(255, 178): 178 data bytes + 77 parity bytes per block.6- * Yields 30.2% ECC, matching the Mayan spec's ECC_RATE = 0.30.5+ * Two API surfaces:76  *8- * The Mayan spec (peach_mayan_spec.md §4) cites "30% ECC" and9- * "RS(255,223)" in the same sentence, but those numbers contradict —10- * RS(255,223) is 12.5% ECC. The encoder code in the spec uses11- * ECC_RATE = 0.30 consistently, so we honor the rate (30%) and pick12- * standard 255-byte blocks at the matching ratio.7+ * 1. Per-block primitives (Mayan v2): rsEncodeBlock / rsDecodeBlock with8+ *    arbitrary parity count. Used for RS(255,204) — 204 data + 51 parity —9+ *    where the Mayan layer does its own blocking and interleaving.1310  *14- * The exposed API matches the call sites in the Mayan spec:15- *16- *   reedSolomonEncode(data, totalParityBytes)17- *   reedSolomonDecode(received, dataBytes, parityBytes)18- *19- * `totalParityBytes` is the spec's `Math.ceil(data.length * ECC_RATE)`;20- * we treat it as advisory and instead generate 77 parity bytes per21- * 178-byte block so the symbol-size math matches what the spec expected.11+ * 2. Legacy block-mode RS(255,178): reedSolomonEncode / reedSolomonDecode12+ *    auto-block a payload into 178/77 blocks. Used by Mayan v1 codes only.13+ *    New v2 codes do not go through this path.2214  */2315 2416 const FIELD_SIZE = 256;@@ -326,3 +318,23 @@326318 export const RS_BLOCK_DATA = BLOCK_DATA;327319 export const RS_BLOCK_PARITY = BLOCK_PARITY;328320 export const RS_BLOCK_TOTAL = BLOCK_TOTAL;321+322+// ── Per-block API (Mayan v2 uses these directly with parity=51) ─────────────323+324+/**325+ * Encode a single shortened RS codeword: append `parity` parity bytes to326+ * `data`, returning a `data.length + parity`-byte block. Caller controls327+ * the block geometry; for RS(255,204) pass 204-byte data + parity=51.328+ */329+export function rsEncodeBlock(data: Uint8Array, parity: number): Uint8Array {330+  return encodeBlock(data, parity);331+}332+333+/**334+ * Decode a single shortened RS codeword. Returns the data portion (without335+ * parity) on success, or null if uncorrectable. Block length and parity must336+ * match what was used at encode time.337+ */338+export function rsDecodeBlock(received: Uint8Array, parity: number): Uint8Array | null {339+  return decodeBlock(received, parity);340+}

view file

src/popup/views/Export.tsx+6 −3
@@ -310,10 +310,13 @@310310       // On-screen render at 3px/module; download render at 4px/module.311311       const screen = encodeMayan(env.bytes, 3);312312       const print = encodeMayan(env.bytes, 4);313-      if (!screen.printable) {314-        // Visible warning but do not abort — user explicitly chose Mayan.313+      if (screen.paperSize === "A3") {315314         setError(316-          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — too large for one A4 page. Save as PNG for digital backup.`,315+          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — print on A3 paper for best results.`,316+        );317+      } else if (screen.paperSize === "A3-required") {318+        setError(319+          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — A3 paper required to print at full resolution. Save as PNG for digital backup.`,317320         );318321       }319322       if (canvasHost) {

view file