1253 lines
46 KiB
TypeScript
Executable file
1253 lines
46 KiB
TypeScript
Executable file
// 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' },
|
||
]
|