openvisengine/src/lib/ava/share-codec.ts
2026-11-04 05:50:06 +00:00

1253 lines
46 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// OVE — Script ⇢ Hash share codec (v7.8 — scheme ▼, "big-base packing").
//
// Turns a script into ONE short shareable Unicode string and back, exactly:
//
// Bass Bloom:[▼䷀…一…CJK/Hangul digits…㘎〠⼁⼃㘚⑔⑒⑑⑔⑕⑓⑑⑓⑖]
// └─ label │└──────────────┘└─┬┘└ check digits
// scheme ▼ 〠 = header separator
//
// WHY IT'S SHORT — the compression ladder (each layer feeds the next):
//
// 1. DICTIONARY — ~300 built-in words → 1 symbol each: every function
// (sin(, cos(, if(, … WITH their open paren), every audio variable
// (bass, mid, treb, vol_att, beatage, …), every header key and value,
// statement openers ("x = " ⇦ "green = " ⇩), the AVS idioms
// ("hue = hue + dt * ", "t = t + dt * ", " * 0.", ") * ", "\n\n[point]\n"),
// ~70 number literals (6.28318, 3.14159, 0.55, 0.25, …) and the
// user-named core: ♤ line return, ♡ beats, ♠ beatage, ♦ space,
// ♩♪♫♬ the four sections.
//
// 2. BIG-BASE PACKING — the clever part. The dictionary output (a string
// over ~40–120 distinct symbols) is read as ONE BIG NUMBER in base
// |alphabet| and rewritten in base 32,164 — one digit per character,
// the digits drawn from the CJK Unified (20,992) + Hangul (11,172)
// blocks. That carries ~15 bits per character where base-62 (the
// classic "1400 → Ma" trick) carries 5.95 — so "1400" costs ~0.85 of
// a character, and a whole preset rides at roughly a THIRD of its
// source length. The decoder gets the alphabet back from a bitmap
// header (the dictionary is built-in, so "which symbols are used" is
// 22 bitmap digits, not 80 literal ones).
//
// 3. PAIR PACKING (fallback) — scripts too short for the header to pay
// off keep the v7.7 body instead: dictionary + two leftover
// characters folded into one CJK code point (the classic layer —
// it reads nicer for small scripts, and the encoder simply picks
// whichever body is shorter).
//
// Guarantees (unchanged from v7.7):
// • LOSSLESS — decode(encode(s)) === s for ANY text (comments, weird
// whitespace, even scripts that don't compile). Anything the dictionary
// doesn't cover passes through untouched, and every reserved symbol
// that legitimately appears in a script is escaped ("\♠" → literal ♠).
// • COPY-SAFE — no raw whitespace or control characters anywhere in the
// string (folded to symbols / control pictures), so chat apps and
// social sites can't collapse it. Emoji-variation selectors and
// zero-width chars that platforms inject while copying are stripped on
// decode (and escaped on encode, so a script that really contains them
// still round-trips).
// • SELF-CHECKING — the last 8 chars are check digits (①–⑯ as hex) over
// an FNV-1a digest of the script, so a string that was cut off by a
// character limit or altered in transit is rejected honestly instead
// of decoding into garbage.
//
// The "Name:" prefix is cosmetic (the script's own `name:` header rides
// inside the body untouched) — it just makes the string self-describing in
// a social feed.
//
// ▲ strings from v7.7 still decode (their tables are kept below, decode
// only). ▼ is everything since.
// ── scheme markers ─────────────────────────────────────────────────────────
/** v7.7 strings (dictionary + pair packing). Decode-only. */
const SCHEME_V1 = '▲'
/** v7.8+ strings (dictionary + big-base or pair, whichever is shorter). */
const SCHEME_V2 = '▼'
/** Escape char — prefixes any reserved symbol that appears literally. */
const ESCAPE = '\\'
/**
* Header separator (U+3020 POSTAL MARK FACE). Splits the payload digits
* from the bitmap header. Reserved, so it only ever appears in a body as
* an escaped pair — the decoder can treat the FIRST bare 〠 as structural.
*/
const SEP = '〠'
interface DictEntry {
key: string
symbol: string
}
// ── the dictionary ─────────────────────────────────────────────────────────
/**
* The substitution dictionary. Longest key wins at every position, so
* "smoothstep(" resolves before "smoothstep", "beatage" before "beat" and
* "hue = hue + dt * " before "hue = ". Symbols are single BMP code points,
* none of them ASCII.
*
* COMPATIBILITY CONTRACT: the symbols of this table (plus the noise
* symbols below) form the frozen BITMAP_LIST that ▼ headers are built
* from. Adding entries to DICT changes BITMAP_LIST and silently breaks
* every ▼ string encoded by an older build — after v7.8 ships, new
* dictionary entries require a new scheme marker (▽), not an edit here.
*/
const DICT: DictEntry[] = [
// ── user-named core references ─────────────────────────────────────────
{ key: '\n', symbol: '♤' }, // line return
{ key: ' ', symbol: '♦' }, // space
{ key: 'beats', symbol: '♡' },
{ key: 'beatage', symbol: '♠' },
{ key: '\t', symbol: '♣' }, // tab
// ── line ends (auto-ported presets are full of these) ──────────────────
{ key: '\r\n', symbol: '⼀' },
{ key: ';\r\n', symbol: '⼁' },
{ key: ';\n', symbol: '⼂' },
{ key: '; ', symbol: '⼃' },
// ── whitespace runs ────────────────────────────────────────────────────
{ key: ' ', symbol: '▣' }, // 4-space indent
{ key: ' ', symbol: '·' }, // 2-space indent
{ key: '\r', symbol: '⏎' }, // lone carriage return
// ── section headers (music notes) ──────────────────────────────────────
{ key: '[init]', symbol: '♩' },
{ key: '[frame]', symbol: '♪' },
{ key: '[beat]', symbol: '♫' },
{ key: '[point]', symbol: '♬' },
// ── blank line + section (the generated-preset skeleton) ───────────────
{ key: '\n\n', symbol: '⽞' },
{ key: '\n\n[init]\n', symbol: '⽟' },
{ key: '\n\n[frame]\n', symbol: '⽠' },
{ key: '\n\n[beat]\n', symbol: '⽡' },
{ key: '\n\n[point]\n', symbol: '⽢' },
// ── comments ───────────────────────────────────────────────────────────
{ key: '# ', symbol: '♯' },
{ key: '#', symbol: '♭' },
{ key: '//', symbol: '♮' },
// ── header keys ────────────────────────────────────────────────────────
{ key: 'description', symbol: '♖' },
{ key: 'linewidth: ', symbol: '⼅' },
{ key: 'blend: additive', symbol: '⼆' },
{ key: 'mode: lines', symbol: '⼇' },
{ key: 'mode: dots', symbol: '⼈' },
{ key: 'linewidth', symbol: '♚' },
{ key: 'persist', symbol: '♙' },
{ key: 'author', symbol: '♕' },
{ key: 'ysign', symbol: '♛' },
{ key: 'blend', symbol: '♘' },
{ key: 'name', symbol: '♔' },
{ key: 'mode', symbol: '♗' },
{ key: ': ', symbol: '⼄' }, // every other header's colon
// ── header values (zodiac) ─────────────────────────────────────────────
{ key: 'additive', symbol: '♋' },
{ key: 'lines', symbol: '♈' },
{ key: 'dots', symbol: '♉' },
{ key: 'solid', symbol: '♊' },
{ key: 'alpha', symbol: '♌' },
// ── built-in variables (the engine's full injected set) ────────────────
{ key: 'bass_att', symbol: '♎' },
{ key: 'mid_att', symbol: '♏' },
{ key: 'treb_att', symbol: '♐' },
{ key: 'vol_att', symbol: '⼉' },
{ key: 'beatphase', symbol: '⼊' },
{ key: 'beatconf', symbol: '⼋' },
{ key: 'bass', symbol: '♑' },
{ key: 'treb', symbol: '♓' },
{ key: 'frame', symbol: '⼌' },
{ key: 'time', symbol: '⼍' },
{ key: 'mid', symbol: '♒' },
{ key: 'vol', symbol: '☽' },
{ key: 'beat', symbol: '◉' },
{ key: 'red', symbol: '☾' },
{ key: 'blue', symbol: '⚑' },
{ key: 'skip', symbol: '⚐' },
{ key: 'green', symbol: '☿' },
{ key: 'aspect', symbol: '⚶' },
{ key: 'chroma', symbol: '⼏' },
{ key: 'dt', symbol: '◐' },
{ key: 'pi', symbol: '◑' },
{ key: 'tau', symbol: '◒' },
{ key: 'bpm', symbol: '⼎' },
{ key: 'hue', symbol: '⋊' },
{ key: 'rad', symbol: '⋅' },
{ key: 'flash', symbol: '∘' },
{ key: 'sv', symbol: '⼐' }, // per-point spectrum level
// ── functions WITH their open paren (a call costs one fewer symbol) ────
{ key: 'smoothstep(', symbol: '⼑' },
{ key: 'gmegabuf(', symbol: '⼒' },
{ key: 'getkbmouse(', symbol: '⼓' },
{ key: 'megabuf(', symbol: '⼔' },
{ key: 'getspec(', symbol: '⼕' },
{ key: 'getosc(', symbol: '⼖' },
{ key: 'gettime(', symbol: '⼗' },
{ key: 'invsqrt(', symbol: '⼘' },
{ key: 'sigmoid(', symbol: '⼙' },
{ key: 'atan2(', symbol: '⼚' },
{ key: 'inrange(', symbol: '⼛' },
{ key: 'log10(', symbol: '⼜' },
{ key: 'floor(', symbol: '⼝' },
{ key: 'above(', symbol: '⼞' },
{ key: 'below(', symbol: '⼟' },
{ key: 'equal(', symbol: '⼠' },
{ key: 'round(', symbol: '⼡' },
{ key: 'clamp(', symbol: '⼢' },
{ key: 'lerp(', symbol: '⼣' },
{ key: 'asin(', symbol: '⼤' },
{ key: 'acos(', symbol: '⼥' },
{ key: 'atan(', symbol: '⼦' },
{ key: 'sign(', symbol: '⼧' },
{ key: 'band(', symbol: '⼨' },
{ key: 'bnot(', symbol: '⼩' },
{ key: 'hsvr(', symbol: '⼪' },
{ key: 'hsvg(', symbol: '⼫' },
{ key: 'hsvb(', symbol: '⼬' },
{ key: 'ceil(', symbol: '⼭' },
{ key: 'frac(', symbol: '⼮' },
{ key: 'pow(', symbol: '⼯' },
{ key: 'exp(', symbol: '⼰' },
{ key: 'log(', symbol: '⼱' },
{ key: 'int(', symbol: '⼲' },
{ key: 'min(', symbol: '⼳' },
{ key: 'max(', symbol: '⼴' },
{ key: 'abs(', symbol: '⼵' },
{ key: 'sin(', symbol: '⼶' },
{ key: 'cos(', symbol: '⼷' },
{ key: 'tan(', symbol: '⼸' },
{ key: 'if(', symbol: '⼹' },
{ key: 'rnd(', symbol: '⼺' },
{ key: 'rand(', symbol: '⼻' },
{ key: 'loop(', symbol: '⼼' },
// ── functions without paren (for the times they're not calls) ──────────
{ key: 'smoothstep', symbol: '⌒' },
{ key: 'gmegabuf', symbol: '✥' },
{ key: 'getkbmouse', symbol: '◶' },
{ key: 'atan2', symbol: '⌗' },
{ key: 'megabuf', symbol: '✤' },
{ key: 'getspec', symbol: '▤' },
{ key: 'getosc', symbol: '▥' },
{ key: 'gettime', symbol: '◷' },
{ key: 'log10', symbol: '㏑' },
{ key: 'invsqrt', symbol: '√' },
{ key: 'inrange', symbol: '⋔' },
{ key: 'sigmoid', symbol: '∿' },
{ key: 'floor', symbol: '⌊' },
{ key: 'above', symbol: '≻' },
{ key: 'below', symbol: '≺' },
{ key: 'asin', symbol: '⌠' },
{ key: 'acos', symbol: '⌡' },
{ key: 'atan', symbol: '⌐' },
{ key: 'ceil', symbol: '⌈' },
{ key: 'sqr', symbol: '□' },
{ key: 'sqrt', symbol: '∛' },
{ key: 'pow', symbol: '⌃' },
{ key: 'exp', symbol: 'ℯ' },
{ key: 'log', symbol: '㏒' },
{ key: 'round', symbol: '◎' },
{ key: 'int', symbol: '⊺' },
{ key: 'frac', symbol: '∵' },
{ key: 'sign', symbol: '±' },
{ key: 'bnot', symbol: '¬' },
{ key: 'band', symbol: '∧' },
{ key: 'bor', symbol: '∨' },
{ key: 'equal', symbol: '≟' },
{ key: 'lerp', symbol: '↝' },
{ key: 'clamp', symbol: '⊜' },
{ key: 'rand', symbol: '⚄' },
{ key: 'rnd', symbol: '⚅' },
{ key: 'sin', symbol: '◠' },
{ key: 'cos', symbol: '◡' },
{ key: 'tan', symbol: '◮' },
{ key: 'abs', symbol: '¦' },
{ key: 'min', symbol: '≪' },
{ key: 'max', symbol: '≫' },
{ key: 'if', symbol: '∴' },
{ key: 'loop', symbol: '↻' },
// ── near-universal user variables (mined from the preset library) ──────
{ key: 'rings', symbol: '⼽' },
{ key: 'spokes', symbol: '⼾' },
{ key: 'travel', symbol: '⼿' },
{ key: 'bars', symbol: '⽀' },
{ key: 'segs', symbol: '⽁' },
{ key: 'ring', symbol: '⽂' },
{ key: 'spoke', symbol: '⽃' },
{ key: 'fade', symbol: '⽄' },
{ key: 'size', symbol: '⽅' },
{ key: 'rot', symbol: '⽆' },
{ key: 'seg', symbol: '⽇' },
{ key: 'col', symbol: '⽈' },
{ key: 'lum', symbol: '⽉' },
{ key: 'amp', symbol: '⽊' },
{ key: 'spin', symbol: '⽋' },
{ key: 'drift', symbol: '⽌' },
{ key: 'orbit', symbol: '⽍' },
{ key: 'pulse', symbol: '⽎' },
{ key: 'glow', symbol: '⽏' },
{ key: 'phase', symbol: '⽐' },
{ key: 'wobble', symbol: '⽑' },
{ key: 'angle', symbol: '⽒' },
// ── structural idioms (mined — the top repeated shapes) ────────────────
{ key: ' * 0.', symbol: '⽓' },
{ key: ' * (0.', symbol: '⽔' },
{ key: '(0.', symbol: '⽕' },
{ key: ') * ', symbol: '⽖' },
{ key: 't * ', symbol: '⽗' },
{ key: '5 + ', symbol: '⽘' },
{ key: ' + dt * ', symbol: '⽙' },
{ key: '(a)', symbol: '⽚' },
{ key: 'hue = hue + dt * ', symbol: '⽛' },
{ key: 't = t + dt * ', symbol: '⽜' },
{ key: ' / aspect', symbol: '⽝' },
// ── number literals (mined from the library) ───────────────────────────
{ key: '6.28318', symbol: '⌇' }, // 2π, written out in most scripts
{ key: '3.14159', symbol: '⽣' },
{ key: '3.1415', symbol: '⽤' },
{ key: '0.55', symbol: '§' },
{ key: '0.45', symbol: '¶' },
{ key: '0.35', symbol: '※' },
{ key: '0.25', symbol: '•' },
{ key: '0.85', symbol: '⊢' },
{ key: '0.75', symbol: '⽥' },
{ key: '0.65', symbol: '⽦' },
{ key: '0.95', symbol: '⽧' },
{ key: '0.15', symbol: '⽨' },
{ key: '0.12', symbol: '⽩' },
{ key: '0.22', symbol: '⽪' },
{ key: '0.42', symbol: '⽫' },
{ key: '0.05', symbol: '⽬' },
{ key: '0.06', symbol: '⽭' },
{ key: '0.04', symbol: '⽮' },
{ key: '0.07', symbol: '⽯' },
{ key: '0.08', symbol: '⽰' },
{ key: '0.96', symbol: '⽱' },
{ key: '0.97', symbol: '⽲' },
{ key: '2.09', symbol: '⽳' },
{ key: '4.19', symbol: '⽴' },
{ key: '0.5', symbol: '†' },
{ key: '0.3', symbol: '‡' },
{ key: '0.4', symbol: '⊤' },
{ key: '0.7', symbol: '⊥' },
{ key: '0.6', symbol: '⊣' },
{ key: '0.9', symbol: '⽵' },
{ key: '0.8', symbol: '⽶' },
{ key: '0.2', symbol: '⽷' },
{ key: '0.1', symbol: '⽸' },
{ key: '1.3', symbol: '⋈' },
{ key: '1.2', symbol: '⋉' },
{ key: '1.0', symbol: '⽹' },
{ key: '100', symbol: '⽺' },
{ key: '2.5', symbol: '⽻' },
{ key: '0.11', symbol: '⽼' },
{ key: '0.18', symbol: '⽽' },
{ key: '0.92', symbol: '⽾' },
{ key: '0.99', symbol: '⽿' },
{ key: '1.5', symbol: '⾀' },
{ key: '0.98', symbol: '⾁' },
{ key: '0.13', symbol: '⾂' },
{ key: '0.14', symbol: '⾃' },
{ key: '0.16', symbol: '⾄' },
{ key: '0.17', symbol: '⾅' },
{ key: '0.19', symbol: '⾆' },
{ key: '0.21', symbol: '⾇' },
{ key: '0.28', symbol: '⾈' },
{ key: '0.31', symbol: '⾉' },
{ key: '0.32', symbol: '⾊' },
{ key: '0.33', symbol: '⾋' },
{ key: '0.38', symbol: '⾌' },
{ key: '0.44', symbol: '⾍' },
{ key: '0.48', symbol: '⾎' },
{ key: '0.52', symbol: '⾏' },
{ key: '0.58', symbol: '⾐' },
{ key: '0.62', symbol: '⾑' },
{ key: '0.72', symbol: '⾒' },
{ key: '0.78', symbol: '⾓' },
{ key: '0.82', symbol: '⾔' },
{ key: '0.88', symbol: '⾕' },
{ key: '1.1', symbol: '⾖' },
{ key: '1.4', symbol: '⾗' },
{ key: '1.6', symbol: '⾘' },
{ key: '1.7', symbol: '⾙' },
{ key: '1.8', symbol: '⾚' },
{ key: '2.0', symbol: '⾛' },
{ key: '2.2', symbol: '⾜' },
{ key: '2.4', symbol: '⾝' },
{ key: '2.6', symbol: '⾞' },
{ key: '3.0', symbol: '⾟' },
{ key: '3.5', symbol: '⾠' },
{ key: '0.87', symbol: '⾡' },
{ key: '0.24', symbol: '⾢' },
{ key: '0.26', symbol: '⾣' },
{ key: '0.27', symbol: '⾤' },
{ key: '0.36', symbol: '⾥' },
// ── spaced operators (arrows) ──────────────────────────────────────────
{ key: ' = ', symbol: '←' },
{ key: ' + ', symbol: '→' },
{ key: ' * ', symbol: '↑' },
{ key: ' - ', symbol: '↓' },
{ key: ' / ', symbol: '∙' },
{ key: ', ', symbol: '⇄' },
// ── statement openers (double arrows) ──────────────────────────────────
{ key: 'green = ', symbol: '⇩' },
{ key: 'blue = ', symbol: '⇪' },
{ key: 'x = ', symbol: '⇦' },
{ key: 'y = ', symbol: '⇨' },
{ key: 'red = ', symbol: '⇧' },
{ key: 'hue = ', symbol: '⇱' },
{ key: 't = ', symbol: '⇲' },
{ key: 'n = ', symbol: '⇴' },
]
/** Noise chars platforms inject into copies (VS16, zero-widths). The
* encoder NEVER emits these raw — a script that legitimately contains one
* gets a dedicated symbol — so the decoder can strip every raw occurrence
* as injected mangling. */
const NOISE_SYMBOLS = new Map<string, string>([
['\uFE0E', '⌸'],
['\uFE0F', '⌹'],
['\u200B', '⌺'],
['\u200C', '⌻'],
['\u200D', '⌼'],
['\u2060', '⌽'],
['\uFEFF', '⌾'],
])
const NOISE_CHAR_FROM_SYMBOL = new Map<string, string>(
[...NOISE_SYMBOLS.entries()].map(([c, s]) => [s, c]),
)
// ── derived tables (built once, self-checked by construction) ─────────────
/** Longest key first, so greedy matching always prefers the longest hit. */
const DICT_SORTED = [...DICT].sort((a, b) => b.key.length - a.key.length)
/** Entries grouped by first char, longest first — the encode lookup. */
const BY_FIRST_CHAR = new Map<string, DictEntry[]>()
for (const entry of DICT_SORTED) {
const list = BY_FIRST_CHAR.get(entry.key[0])
if (list) list.push(entry)
else BY_FIRST_CHAR.set(entry.key[0], [entry])
}
/** Symbol → raw text (decode lookup). Throws on duplicate symbols — a
* broken table must fail loudly at import, never corrupt a share string. */
const SYMBOL_TO_TEXT = new Map<string, string>()
for (const { key, symbol } of DICT) {
if (SYMBOL_TO_TEXT.has(symbol)) throw new Error(`share-codec: duplicate dictionary symbol ${symbol}`)
SYMBOL_TO_TEXT.set(symbol, key)
}
const ALL_SYMBOLS = new Set<string>(DICT.map((e) => e.symbol))
for (const s of NOISE_SYMBOLS.values()) ALL_SYMBOLS.add(s)
/**
* The FROZEN bitmap list — every dictionary + noise symbol, sorted by code
* point. A ▼ header says "these of the list are in this string's alphabet"
* as a bitmap, 14 bits per digit character. This array is part of the ▼
* wire format: changing it breaks every ▼ string ever encoded (see the
* DICT compatibility contract above).
*/
const BITMAP_LIST: string[] = [...ALL_SYMBOLS].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0))
const BITMAP_INDEX = new Map<string, number>(BITMAP_LIST.map((s, i) => [s, i]))
const DICT_BITMAP_DIGITS = Math.ceil(BITMAP_LIST.length / 14) // 14 bits per digit
const ASCII_BITMAP_DIGITS = Math.ceil(128 / 14)
/**
* Every character that can never appear raw in a body — emitted by the
* encoder only with a "\" escape in front (pair form) or folded into the
* bitmaps (base form). Covers the dictionary symbols, both scheme markers,
* the separator, the checksum alphabet (①–⑯), the control-picture range
* (␀–␡) and the invisible characters chat platforms inject.
*/
const RESERVED = new Set<string>([ESCAPE, SCHEME_V1, SCHEME_V2, SEP])
for (const s of ALL_SYMBOLS) RESERVED.add(s)
for (let c = 0x2460; c <= 0x246f; c++) RESERVED.add(String.fromCharCode(c)) // ①–⑯
for (let c = 0x2400; c <= 0x2421; c++) RESERVED.add(String.fromCharCode(c)) // ␀–␡
for (const c of ['\uFE0E', '\uFE0F', '\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF']) {
RESERVED.add(c)
}
const PLATFORM_NOISE = /[\uFE0E\uFE0F\u200B\u200C\u200D\u2060\uFEFF]/g
/** Whitespace or platform noise — safe to shave off the string's edges. */
function isEdgeTrimable(c: string): boolean {
return c === ' ' || c === '\n' || c === '\r' || c === '\t' || PLATFORM_NOISE.test(c)
}
// ── pair packing (shared by the ▼ fallback form and legacy ▲ strings) ──────
/**
* Two adjacent characters from this 64-char alphabet fold into ONE code
* point at PAIR_BASE + 64·a + b (a common-CJK block — assigned since
* Unicode 1.1, text presentation, renders everywhere, copy/paste-safe).
* The block is reserved, so CJK that legitimately appears in a script is
* escaped and still round-trips.
*/
const PAIR_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.'
const PAIR_BASE = 0x4e00
const PAIR_END = PAIR_BASE + PAIR_ALPHABET.length * PAIR_ALPHABET.length - 1 // 0x5dff
const PAIR_INDEX = new Map<string, number>()
for (let i = 0; i < PAIR_ALPHABET.length; i++) PAIR_INDEX.set(PAIR_ALPHABET[i], i)
for (let c = PAIR_BASE; c <= PAIR_END; c++) RESERVED.add(String.fromCharCode(c))
// ── big-base packing ───────────────────────────────────────────────────────
/**
* The digit alphabet for the base form: CJK Unified Ideographs (20,992) +
* Hangul syllables (11,172) = 32,164 digits — every one assigned, text
* presentation, in every CJK-capable font, and copy/paste-safe. 14.98
* bits per character, vs 5.95 for classic base-62.
*/
const DIGIT_BLOCKS: Array<[number, number]> = [
[0x4e00, 0x9fff], // CJK Unified Ideographs
[0xac00, 0xd7a3], // Hangul syllables
]
const DIGIT_BLOCK0_SIZE = DIGIT_BLOCKS[0][1] - DIGIT_BLOCKS[0][0] + 1
const BIG_BASE = DIGIT_BLOCK0_SIZE + (DIGIT_BLOCKS[1][1] - DIGIT_BLOCKS[1][0] + 1) // 32164
const BIG_BASE_N = BigInt(BIG_BASE)
const BITS_PER_DIGIT = 14 // floor(log2(32164)) -- bitmap digits carry 14 bits
function digitChar(d: number): string {
return d < DIGIT_BLOCK0_SIZE
? String.fromCharCode(DIGIT_BLOCKS[0][0] + d)
: String.fromCharCode(DIGIT_BLOCKS[1][0] + (d - DIGIT_BLOCK0_SIZE))
}
/** Digit char → value, or -1 when the char isn't a digit. */
function digitValue(c: string): number {
const cc = c.charCodeAt(0)
if (cc >= DIGIT_BLOCKS[0][0] && cc <= DIGIT_BLOCKS[0][1]) return cc - DIGIT_BLOCKS[0][0]
if (cc >= DIGIT_BLOCKS[1][0] && cc <= DIGIT_BLOCKS[1][1]) return cc - DIGIT_BLOCKS[1][0] + DIGIT_BLOCK0_SIZE
return -1
}
/** Bits → exactly `digits` digit chars (left-padded with digit 0). */
function bitsToDigits(bits: boolean[], digits: number): string {
let value = 0n
for (const b of bits) value = value * 2n + (b ? 1n : 0n)
const ds: number[] = []
let v = value
while (v > 0n) {
ds.push(Number(v % BIG_BASE_N))
v /= BIG_BASE_N
}
while (ds.length < digits) ds.push(0)
if (ds.length > digits) throw new Error('share-codec: bitmap overflow')
let out = ''
for (let k = ds.length - 1; k >= 0; k--) out += digitChar(ds[k])
return out
}
/** The first `count` digit chars of `s` → bits (big-endian, `bits` long). */
function digitsToBits(s: string, count: number, bits: number): boolean[] {
let value = 0n
for (let i = 0; i < count; i++) {
const d = digitValue(s[i])
if (d < 0) throw new Error('share-codec: bad bitmap digit')
value = value * BIG_BASE_N + BigInt(d)
}
const out: boolean[] = []
for (let i = bits - 1; i >= 0; i--) out.push((value >> BigInt(i)) & 1n ? true : false)
return out
}
/**
* Largest body (in symbols) the big-base packer will accept — packing is
* O(n²) in BigInt arithmetic, and 16,000 symbols packs in ~150 ms while a
* 50,000-symbol body would take seconds. Longer scripts fall back to the
* linear pair form (still ~55% shorter).
*/
const PACK_SYMBOL_LIMIT = 16_000
/**
* Pack a dictionary body into the base form:
*
* <payload digits> 〠 <dict bitmap> <ascii bitmap> <raw non-ASCII tail>
*
* The payload is the whole body as one big number (a leading sentinel
* symbol — the alphabet's last — guarantees leading-alphabet-symbol zeros
* survive). The header rebuilds the body's alphabet for the decoder: which
* frozen dictionary symbols are used (bitmap), which ASCII chars are used
* (bitmap), and any remaining non-ASCII chars verbatim.
*/
function packBase(body: string): string | null {
const n = body.length
if (n < 2 || n > PACK_SYMBOL_LIMIT) return null
const used = new Set<string>()
for (let i = 0; i < n; i++) used.add(body[i])
const alphabet = [...used].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0))
const m = alphabet.length
if (m < 2) return null
const indexOf = new Map<string, number>()
alphabet.forEach((s, idx) => indexOf.set(s, idx))
// value = sentinel·m^n + body as a base-m number
let value = BigInt(m - 1)
for (let i = 0; i < n; i++) value = value * BigInt(m) + BigInt(indexOf.get(body[i])!)
let payload = ''
const ds: number[] = []
let v = value
while (v > 0n) {
ds.push(Number(v % BIG_BASE_N))
v /= BIG_BASE_N
}
for (let k = ds.length - 1; k >= 0; k--) payload += digitChar(ds[k])
// header bitmaps
const dictBits: boolean[] = []
for (const s of BITMAP_LIST) dictBits.push(used.has(s))
const asciiBits: boolean[] = []
for (let c = 0; c < 128; c++) asciiBits.push(used.has(String.fromCharCode(c)))
let tail = ''
for (const s of alphabet) {
const cc = s.charCodeAt(0)
if (cc >= 128 && !BITMAP_INDEX.has(s)) tail += s
}
return (
payload + SEP + bitsToDigits(dictBits, DICT_BITMAP_DIGITS) + bitsToDigits(asciiBits, ASCII_BITMAP_DIGITS) + tail
)
}
/** Inverse of packBase — returns null on any structural violation. */
function unpackBase(packed: string): string | null {
// first bare SEP splits payload | header (payload digits are CJK/Hangul,
// so no digit can ever be mistaken for the separator)
let sepIdx = -1
for (let i = 0; i < packed.length; i++) {
if (packed[i] === SEP) {
sepIdx = i
break
}
}
if (sepIdx < 1 || sepIdx === packed.length - 1) return null
let value = 0n
for (let i = 0; i < sepIdx; i++) {
const d = digitValue(packed[i])
if (d < 0) return null
value = value * BIG_BASE_N + BigInt(d)
}
if (value <= 0n) return null
const header = packed.slice(sepIdx + 1)
if (header.length < DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS) return null
let dictBits: boolean[]
let asciiBits: boolean[]
try {
dictBits = digitsToBits(header, DICT_BITMAP_DIGITS, BITMAP_LIST.length)
asciiBits = digitsToBits(header.slice(DICT_BITMAP_DIGITS), ASCII_BITMAP_DIGITS, 128)
} catch {
return null
}
const tail = header.slice(DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS)
// rebuild the alphabet: frozen symbols + ASCII + verbatim tail, sorted
const alphabet: string[] = []
dictBits.forEach((on, i) => {
if (on) alphabet.push(BITMAP_LIST[i])
})
asciiBits.forEach((on, c) => {
if (on) alphabet.push(String.fromCharCode(c))
})
const seen = new Set<string>(alphabet)
for (let i = 0; i < tail.length; i++) {
const c = tail[i]
if (seen.has(c)) return null // tail must be deduped
seen.add(c)
alphabet.push(c)
}
alphabet.sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0))
const m = alphabet.length
if (m < 2) return null
const M = BigInt(m)
const idxs: number[] = []
while (value > 0n) {
idxs.push(Number(value % M))
value /= M
}
let s = ''
for (let k = idxs.length - 1; k >= 0; k--) {
const idx = idxs[k]
if (idx >= m) return null
s += alphabet[idx]
}
if (s.length < 1) return null
return s.slice(1) // strip the sentinel
}
// ── checksum ──────────────────────────────────────────────────────────────
/** FNV-1a 32-bit over the UTF-16 code units — fast, tiny, plenty here. */
function fnv1a(s: string): number {
let h = 0x811c9dc5
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 0x01000193) >>> 0
}
return h >>> 0
}
/** Encode a 32-bit digest as 8 "circled digit" hex chars (① = 0 … ⑯ = f). */
function checkDigits(value: number): string {
let out = ''
for (let shift = 28; shift >= 0; shift -= 4) {
out += String.fromCharCode(0x2460 + ((value >>> shift) & 0xf))
}
return out
}
function isCheckDigit(c: string): boolean {
const cc = c.charCodeAt(0)
return cc >= 0x2460 && cc <= 0x246f
}
// ── name label ────────────────────────────────────────────────────────────
/** Pull the `name:` header out of a script (before the first [section]). */
function extractScriptName(script: string): string {
for (const line of script.split('\n')) {
const t = line.trim()
if (t.startsWith('[')) break
const m = /^name\s*:\s*(.*)$/i.exec(t)
if (m) {
let v = m[1].trim()
if (
v.length >= 2 &&
((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
) {
v = v.slice(1, -1).trim()
}
if (v) return v
}
}
return ''
}
/** Make the label safe for the `Name:[…]` wrapper (no : [ ] or newlines). */
function sanitizeShareName(raw: string): string {
const cleaned = raw
.replace(/[\[\]:\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 48)
.trim()
return cleaned || 'OVE'
}
// ── encode ────────────────────────────────────────────────────────────────
/** Substitute the dictionary into the script text (escaping as needed). */
function encodeDictBody(script: string): string {
let out = ''
let i = 0
const n = script.length
while (i < n) {
const c = script[i]
// legitimate noise chars → their dedicated symbols (never raw)
const noiseSym = NOISE_SYMBOLS.get(c)
if (noiseSym !== undefined) {
out += noiseSym
i++
continue
}
// reserved / escape char → escaped literal
if (c === ESCAPE || RESERVED.has(c)) {
out += ESCAPE + c
i++
continue
}
// dictionary match (longest first) — also folds \n \r \t into symbols
const entries = BY_FIRST_CHAR.get(c)
if (entries) {
let matched = false
for (const e of entries) {
if (script.startsWith(e.key, i)) {
out += e.symbol
i += e.key.length
matched = true
break
}
}
if (matched) continue
}
// other control characters → Unicode control pictures (copy-safe)
const cc = c.charCodeAt(0)
if (cc < 0x20 || cc === 0x7f) {
out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc)
i++
continue
}
out += c
i++
}
return out
}
/** Dictionary + pair packing interleaved (the fallback form, v7.7-style). */
function encodePairBody(script: string): string {
let out = ''
let i = 0
const n = script.length
while (i < n) {
const c = script[i]
const noiseSym = NOISE_SYMBOLS.get(c)
if (noiseSym !== undefined) {
out += noiseSym
i++
continue
}
if (c === ESCAPE || RESERVED.has(c)) {
out += ESCAPE + c
i++
continue
}
const entries = BY_FIRST_CHAR.get(c)
if (entries) {
let matched = false
for (const e of entries) {
if (script.startsWith(e.key, i)) {
out += e.symbol
i += e.key.length
matched = true
break
}
}
if (matched) continue
}
const cc = c.charCodeAt(0)
if (cc < 0x20 || cc === 0x7f) {
out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc)
i++
continue
}
// pair packing — two adjacent [0-9a-zA-Z_.] chars → one CJK code point
const ai = PAIR_INDEX.get(c)
if (ai !== undefined) {
const bi = PAIR_INDEX.get(script[i + 1])
if (bi !== undefined) {
out += String.fromCharCode(PAIR_BASE + ai * PAIR_ALPHABET.length + bi)
i += 2
continue
}
}
out += c
i++
}
return out
}
export interface ScriptHash {
/** The full share string: `Name:[▼…①①①…]`. */
hash: string
/** The label used in the prefix (sanitized). */
name: string
/** script.length (UTF-16 units) — for the stats line. */
scriptLength: number
/** hash.length — for the stats line. */
hashLength: number
}
/**
* Encode a script into its share string. Never throws, never validates the
* script — sharing a script that doesn't compile is a feature (ask for help,
* get it debugged). `name` overrides the label; by default the `name:`
* header is used, falling back to "OVE".
*/
export function encodeScriptHash(script: string, name?: string): ScriptHash {
const label = sanitizeShareName(name ?? extractScriptName(script))
const dictBody = encodeDictBody(script)
const packed = packBase(dictBody)
let body: string
if (packed !== null) {
const pairBody = encodePairBody(script)
body = packed.length < pairBody.length ? packed : pairBody
} else {
body = encodePairBody(script)
}
const hash = `${label}:[${SCHEME_V2}${body}${checkDigits(fnv1a(script))}]`
return { hash, name: label, scriptLength: script.length, hashLength: hash.length }
}
// ── decode ────────────────────────────────────────────────────────────────
/** Expand a dictionary body back into the original text. */
function decodeDictBody(body: string): string {
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i]
if (c === ESCAPE) {
const next = body[i + 1]
if (next !== undefined) {
out += next
i++
} else {
out += c // trailing lone escape -- be lenient
}
continue
}
const cc = c.charCodeAt(0)
// control pictures → the control chars they stand for
if (cc >= 0x2400 && cc <= 0x2420) {
out += String.fromCharCode(cc - 0x2400)
continue
}
if (cc === 0x2421) {
out += '\u007f'
continue
}
// dictionary symbols → raw text
const expansion = SYMBOL_TO_TEXT.get(c)
if (expansion !== undefined) {
out += expansion
continue
}
// legitimate noise symbols → their original chars
const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c)
if (noiseChar !== undefined) {
out += noiseChar
continue
}
out += c
}
return out
}
/** Expand a pair-form body (dictionary + pair slots) into the script. */
function decodePairBody(body: string): string {
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i]
if (c === ESCAPE) {
const next = body[i + 1]
if (next !== undefined) {
out += next
i++
} else {
out += c
}
continue
}
const cc = c.charCodeAt(0)
if (cc >= 0x2400 && cc <= 0x2420) {
out += String.fromCharCode(cc - 0x2400)
continue
}
if (cc === 0x2421) {
out += '\u007f'
continue
}
const expansion = SYMBOL_TO_TEXT.get(c)
if (expansion !== undefined) {
out += expansion
continue
}
const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c)
if (noiseChar !== undefined) {
out += noiseChar
continue
}
if (cc >= PAIR_BASE && cc <= PAIR_END) {
const k = cc - PAIR_BASE
out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length]
continue
}
out += c
}
return out
}
// ── legacy ▲ support (v7.7 strings — decode only) ──────────────────────────
const LEGACY_DICT: DictEntry[] = [
{ key: '\n', symbol: '♤' },
{ key: ' ', symbol: '♦' },
{ key: 'beats', symbol: '♡' },
{ key: 'beatage', symbol: '♠' },
{ key: '\t', symbol: '♣' },
{ key: ' ', symbol: '▣' },
{ key: ' ', symbol: '·' },
{ key: '\r', symbol: '⏎' },
{ key: '[init]', symbol: '♩' },
{ key: '[frame]', symbol: '♪' },
{ key: '[beat]', symbol: '♫' },
{ key: '[point]', symbol: '♬' },
{ key: '# ', symbol: '♯' },
{ key: '#', symbol: '♭' },
{ key: '//', symbol: '♮' },
{ key: 'name', symbol: '♔' },
{ key: 'author', symbol: '♕' },
{ key: 'description', symbol: '♖' },
{ key: 'mode', symbol: '♗' },
{ key: 'blend', symbol: '♘' },
{ key: 'persist', symbol: '♙' },
{ key: 'linewidth', symbol: '♚' },
{ key: 'ysign', symbol: '♛' },
{ key: 'lines', symbol: '♈' },
{ key: 'dots', symbol: '♉' },
{ key: 'solid', symbol: '♊' },
{ key: 'additive', symbol: '♋' },
{ key: 'alpha', symbol: '♌' },
{ key: ' = ', symbol: '←' },
{ key: ' + ', symbol: '→' },
{ key: ' * ', symbol: '↑' },
{ key: ' - ', symbol: '↓' },
{ key: ', ', symbol: '⇄' },
{ key: 'x = ', symbol: '⇦' },
{ key: 'y = ', symbol: '⇨' },
{ key: 'red = ', symbol: '⇧' },
{ key: 'green = ', symbol: '⇩' },
{ key: 'blue = ', symbol: '⇪' },
{ key: 'hue = ', symbol: '⇱' },
{ key: 't = ', symbol: '⇲' },
{ key: 'n = ', symbol: '⇴' },
{ key: 'bass_att', symbol: '♎' },
{ key: 'mid_att', symbol: '♏' },
{ key: 'treb_att', symbol: '♐' },
{ key: 'bass', symbol: '♑' },
{ key: 'mid', symbol: '♒' },
{ key: 'treb', symbol: '♓' },
{ key: 'vol', symbol: '☽' },
{ key: 'beat', symbol: '◉' },
{ key: 'red', symbol: '☾' },
{ key: 'green', symbol: '☿' },
{ key: 'blue', symbol: '⚑' },
{ key: 'skip', symbol: '⚐' },
{ key: 'aspect', symbol: '⚶' },
{ key: 'dt', symbol: '◐' },
{ key: 'pi', symbol: '◑' },
{ key: 'tau', symbol: '◒' },
{ key: 'smoothstep', symbol: '⌒' },
{ key: 'gmegabuf', symbol: '✥' },
{ key: 'getkbmouse', symbol: '◶' },
{ key: 'megabuf', symbol: '✤' },
{ key: 'getspec', symbol: '▤' },
{ key: 'getosc', symbol: '▥' },
{ key: 'gettime', symbol: '◷' },
{ key: 'atan2', symbol: '⌗' },
{ key: 'log10', symbol: '㏑' },
{ key: 'invsqrt', symbol: '√' },
{ key: 'sigmoid', symbol: '∿' },
{ key: 'inrange', symbol: '⋔' },
{ key: 'hsvr', symbol: '⬥' },
{ key: 'hsvg', symbol: '⬦' },
{ key: 'hsvb', symbol: '⬧' },
{ key: 'above', symbol: '≻' },
{ key: 'below', symbol: '≺' },
{ key: 'asin', symbol: '⌠' },
{ key: 'acos', symbol: '⌡' },
{ key: 'atan', symbol: '⌐' },
{ key: 'ceil', symbol: '⌈' },
{ key: 'floor', symbol: '⌊' },
{ key: 'sqr', symbol: '□' },
{ key: 'sqrt', symbol: '∛' },
{ key: 'pow', symbol: '⌃' },
{ key: 'exp', symbol: 'ℯ' },
{ key: 'log', symbol: '㏒' },
{ key: 'round', symbol: '◎' },
{ key: 'int', symbol: '⊺' },
{ key: 'frac', symbol: '∵' },
{ key: 'sign', symbol: '±' },
{ key: 'bnot', symbol: '¬' },
{ key: 'band', symbol: '∧' },
{ key: 'bor', symbol: '∨' },
{ key: 'equal', symbol: '≟' },
{ key: 'lerp', symbol: '↝' },
{ key: 'clamp', symbol: '⊜' },
{ key: 'rand', symbol: '⚄' },
{ key: 'rnd', symbol: '⚅' },
{ key: 'sin', symbol: '◠' },
{ key: 'cos', symbol: '◡' },
{ key: 'tan', symbol: '◮' },
{ key: 'abs', symbol: '¦' },
{ key: 'min', symbol: '≪' },
{ key: 'max', symbol: '≫' },
{ key: 'if', symbol: '∴' },
{ key: 'loop', symbol: '↻' },
{ key: '6.28318', symbol: '⌇' },
{ key: '0.5', symbol: '†' },
{ key: '0.3', symbol: '‡' },
{ key: '0.25', symbol: '•' },
{ key: '0.55', symbol: '§' },
{ key: '0.45', symbol: '¶' },
{ key: '0.35', symbol: '※' },
{ key: '0.4', symbol: '⊤' },
{ key: '0.7', symbol: '⊥' },
{ key: '0.85', symbol: '⊢' },
{ key: '0.6', symbol: '⊣' },
{ key: '1.3', symbol: '⋈' },
{ key: '1.2', symbol: '⋉' },
{ key: ' / ', symbol: '∙' },
{ key: 'hue', symbol: '⋊' },
{ key: 'rad', symbol: '⋅' },
{ key: 'flash', symbol: '∘' },
]
const LEGACY_SYMBOL_TO_TEXT = new Map<string, string>()
for (const { key, symbol } of LEGACY_DICT) LEGACY_SYMBOL_TO_TEXT.set(symbol, key)
for (const [c, s] of NOISE_SYMBOLS) LEGACY_SYMBOL_TO_TEXT.set(s, c)
/** Expand a v7.7 ▲ body. */
function decodeLegacyBody(body: string): string {
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i]
if (c === ESCAPE) {
const next = body[i + 1]
if (next !== undefined) {
out += next
i++
} else {
out += c
}
continue
}
const cc = c.charCodeAt(0)
if (cc >= 0x2400 && cc <= 0x2420) {
out += String.fromCharCode(cc - 0x2400)
continue
}
if (cc === 0x2421) {
out += '\u007f'
continue
}
const expansion = LEGACY_SYMBOL_TO_TEXT.get(c)
if (expansion !== undefined) {
out += expansion
continue
}
if (cc >= PAIR_BASE && cc <= PAIR_END) {
const k = cc - PAIR_BASE
out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length]
continue
}
out += c
}
return out
}
// ── decode entry point ────────────────────────────────────────────────────
export type HashDecodeResult =
| { ok: true; script: string; name: string | null }
| { ok: false; error: string }
/**
* Decode a pasted share string back into the original script. Accepts the
* full `Name:[…]` form or a bare `[…]` body, ▼ (current) and ▲ (v7.7)
* schemes; strips the invisible characters platforms sprinkle into copies;
* and verifies the check digits so a truncated or altered string is
* rejected instead of decoding into a corrupted script.
*/
export function decodeScriptHash(input: string): HashDecodeResult {
if (input.length > 40_000) {
return { ok: false, error: 'That text is far too long to be a share string.' }
}
// Trim whitespace AND stray noise from the edges (the encoder never emits
// either raw, so edge removal is always safe).
let text = input
while (text.length > 0 && isEdgeTrimable(text[0])) text = text.slice(1)
while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1)
if (
text.length >= 2 &&
((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
) {
text = text.slice(1, -1)
while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1)
}
// Any raw noise left is injected mangling (legit noise is symbol-coded),
// so it can be stripped everywhere without ever eating original content.
text = text.replace(PLATFORM_NOISE, '')
// `Name:[▼…]`, `[▼…]`, `Name:[▲…]` or `[▲…]`
let payload: string | null = null
let name: string | null = null
const full = /^([\s\S]*?):\[([▼▲][\s\S]*)\]$/.exec(text)
if (full) {
name = full[1].trim() || null
payload = full[2]
} else if (/^\[([▼▲][\s\S]*)\]$/.test(text)) {
payload = text.slice(1, -1)
}
if (payload === null) {
// a string that still shows the "…:[▼/▲" signature was cut off
// somewhere after the opening — a character limit ate the end
const looksCutOff = /:\[[▼▲]/.test(text) || /^\[[▼▲]/.test(text)
return {
ok: false,
error: looksCutOff
? 'This share string looks cut off -- it should end with "]". Copy the whole string and try again.'
: 'This does not look like an OVE share string -- it should read like "My Script:[▼…]".',
}
}
// trailing 8 check digits
if (payload.length < 9 || !isCheckDigit(payload[payload.length - 8])) {
return {
ok: false,
error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.',
}
}
const digits = payload.slice(-8)
for (const d of digits) {
if (!isCheckDigit(d)) {
return {
ok: false,
error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.',
}
}
}
const marker = payload[0]
const body = payload.slice(1, -8)
let script: string
if (marker === SCHEME_V2) {
// base form ⇔ the body contains a bare (unescaped) 〠 — the separator
// between payload digits and the bitmap header. The pair form escapes
// every 〠, and base-form payload digits can never be one.
let hasBareSep = false
for (let i = 0; i < body.length; i++) {
if (body[i] === ESCAPE) i++
else if (body[i] === SEP) {
hasBareSep = true
break
}
}
if (hasBareSep) {
const dictBody = unpackBase(body)
if (dictBody === null) {
return {
ok: false,
error: 'This share string is damaged -- its packing header does not parse. Copy the whole string and try again.',
}
}
script = decodeDictBody(dictBody)
} else {
script = decodePairBody(body)
}
} else {
script = decodeLegacyBody(body)
}
if (checkDigits(fnv1a(script)) !== digits) {
return {
ok: false,
error: 'The check digits do not match -- the string was altered or cut off while copying. Copy the whole string and try again.',
}
}
return { ok: true, script, name }
}
// ── legend for the UI ─────────────────────────────────────────────────────
/** A few representative symbol meanings, shown in the share dialogs. */
export const SHARE_LEGEND: ReadonlyArray<{ symbol: string; meaning: string }> = [
{ symbol: '♤', meaning: 'line return' },
{ symbol: '♦', meaning: 'space' },
{ symbol: '♡', meaning: 'beats' },
{ symbol: '♠', meaning: 'beatage' },
{ symbol: '一', meaning: 'one packed digit -- 15 bits of script' },
]