1→// OVE — Script ⇢ Hash share codec (v7.8 — scheme ▼, "big-base packing"). 2→// 3→// Turns a script into ONE short shareable Unicode string and back, exactly: 4→// 5→// Bass Bloom:[▼䷀…一…CJK/Hangul digits…㘎〠⼁⼃㘚⑔⑒⑑⑔⑕⑓⑑⑓⑖] 6→// └─ label │└──────────────┘└─┬┘└ check digits 7→// scheme ▼ 〠 = header separator 8→// 9→// WHY IT'S SHORT — the compression ladder (each layer feeds the next): 10→// 11→// 1. DICTIONARY — ~300 built-in words → 1 symbol each: every function 12→// (sin(, cos(, if(, … WITH their open paren), every audio variable 13→// (bass, mid, treb, vol_att, beatage, …), every header key and value, 14→// statement openers ("x = " ⇦ "green = " ⇩), the AVS idioms 15→// ("hue = hue + dt * ", "t = t + dt * ", " * 0.", ") * ", "\n\n[point]\n"), 16→// ~70 number literals (6.28318, 3.14159, 0.55, 0.25, …) and the 17→// user-named core: ♤ line return, ♡ beats, ♠ beatage, ♦ space, 18→// ♩♪♫♬ the four sections. 19→// 20→// 2. BIG-BASE PACKING — the clever part. The dictionary output (a string 21→// over ~40–120 distinct symbols) is read as ONE BIG NUMBER in base 22→// |alphabet| and rewritten in base 32,164 — one digit per character, 23→// the digits drawn from the CJK Unified (20,992) + Hangul (11,172) 24→// blocks. That carries ~15 bits per character where base-62 (the 25→// classic "1400 → Ma" trick) carries 5.95 — so "1400" costs ~0.85 of 26→// a character, and a whole preset rides at roughly a THIRD of its 27→// source length. The decoder gets the alphabet back from a bitmap 28→// header (the dictionary is built-in, so "which symbols are used" is 29→// 22 bitmap digits, not 80 literal ones). 30→// 31→// 3. PAIR PACKING (fallback) — scripts too short for the header to pay 32→// off keep the v7.7 body instead: dictionary + two leftover 33→// characters folded into one CJK code point (the classic layer — 34→// it reads nicer for small scripts, and the encoder simply picks 35→// whichever body is shorter). 36→// 37→// Guarantees (unchanged from v7.7): 38→// • LOSSLESS — decode(encode(s)) === s for ANY text (comments, weird 39→// whitespace, even scripts that don't compile). Anything the dictionary 40→// doesn't cover passes through untouched, and every reserved symbol 41→// that legitimately appears in a script is escaped ("\♠" → literal ♠). 42→// • COPY-SAFE — no raw whitespace or control characters anywhere in the 43→// string (folded to symbols / control pictures), so chat apps and 44→// social sites can't collapse it. Emoji-variation selectors and 45→// zero-width chars that platforms inject while copying are stripped on 46→// decode (and escaped on encode, so a script that really contains them 47→// still round-trips). 48→// • SELF-CHECKING — the last 8 chars are check digits (①–⑯ as hex) over 49→// an FNV-1a digest of the script, so a string that was cut off by a 50→// character limit or altered in transit is rejected honestly instead 51→// of decoding into garbage. 52→// 53→// The "Name:" prefix is cosmetic (the script's own `name:` header rides 54→// inside the body untouched) — it just makes the string self-describing in 55→// a social feed. 56→// 57→// ▲ strings from v7.7 still decode (their tables are kept below, decode 58→// only). ▼ is everything since. 59→ 60→// ── scheme markers ───────────────────────────────────────────────────────── 61→ 62→/** v7.7 strings (dictionary + pair packing). Decode-only. */ 63→const SCHEME_V1 = '▲' 64→/** v7.8+ strings (dictionary + big-base or pair, whichever is shorter). */ 65→const SCHEME_V2 = '▼' 66→ 67→/** Escape char — prefixes any reserved symbol that appears literally. */ 68→const ESCAPE = '\\' 69→ 70→/** 71→ * Header separator (U+3020 POSTAL MARK FACE). Splits the payload digits 72→ * from the bitmap header. Reserved, so it only ever appears in a body as 73→ * an escaped pair — the decoder can treat the FIRST bare 〠 as structural. 74→ */ 75→const SEP = '〠' 76→ 77→interface DictEntry { 78→ key: string 79→ symbol: string 80→} 81→ 82→// ── the dictionary ───────────────────────────────────────────────────────── 83→ 84→/** 85→ * The substitution dictionary. Longest key wins at every position, so 86→ * "smoothstep(" resolves before "smoothstep", "beatage" before "beat" and 87→ * "hue = hue + dt * " before "hue = ". Symbols are single BMP code points, 88→ * none of them ASCII. 89→ * 90→ * COMPATIBILITY CONTRACT: the symbols of this table (plus the noise 91→ * symbols below) form the frozen BITMAP_LIST that ▼ headers are built 92→ * from. Adding entries to DICT changes BITMAP_LIST and silently breaks 93→ * every ▼ string encoded by an older build — after v7.8 ships, new 94→ * dictionary entries require a new scheme marker (▽), not an edit here. 95→ */ 96→const DICT: DictEntry[] = [ 97→ // ── user-named core references ───────────────────────────────────────── 98→ { key: '\n', symbol: '♤' }, // line return 99→ { key: ' ', symbol: '♦' }, // space 100→ { key: 'beats', symbol: '♡' }, 101→ { key: 'beatage', symbol: '♠' }, 102→ { key: '\t', symbol: '♣' }, // tab 103→ // ── line ends (auto-ported presets are full of these) ────────────────── 104→ { key: '\r\n', symbol: '⼀' }, 105→ { key: ';\r\n', symbol: '⼁' }, 106→ { key: ';\n', symbol: '⼂' }, 107→ { key: '; ', symbol: '⼃' }, 108→ // ── whitespace runs ──────────────────────────────────────────────────── 109→ { key: ' ', symbol: '▣' }, // 4-space indent 110→ { key: ' ', symbol: '·' }, // 2-space indent 111→ { key: '\r', symbol: '⏎' }, // lone carriage return 112→ // ── section headers (music notes) ────────────────────────────────────── 113→ { key: '[init]', symbol: '♩' }, 114→ { key: '[frame]', symbol: '♪' }, 115→ { key: '[beat]', symbol: '♫' }, 116→ { key: '[point]', symbol: '♬' }, 117→ // ── blank line + section (the generated-preset skeleton) ─────────────── 118→ { key: '\n\n', symbol: '⽞' }, 119→ { key: '\n\n[init]\n', symbol: '⽟' }, 120→ { key: '\n\n[frame]\n', symbol: '⽠' }, 121→ { key: '\n\n[beat]\n', symbol: '⽡' }, 122→ { key: '\n\n[point]\n', symbol: '⽢' }, 123→ // ── comments ─────────────────────────────────────────────────────────── 124→ { key: '# ', symbol: '♯' }, 125→ { key: '#', symbol: '♭' }, 126→ { key: '//', symbol: '♮' }, 127→ // ── header keys ──────────────────────────────────────────────────────── 128→ { key: 'description', symbol: '♖' }, 129→ { key: 'linewidth: ', symbol: '⼅' }, 130→ { key: 'blend: additive', symbol: '⼆' }, 131→ { key: 'mode: lines', symbol: '⼇' }, 132→ { key: 'mode: dots', symbol: '⼈' }, 133→ { key: 'linewidth', symbol: '♚' }, 134→ { key: 'persist', symbol: '♙' }, 135→ { key: 'author', symbol: '♕' }, 136→ { key: 'ysign', symbol: '♛' }, 137→ { key: 'blend', symbol: '♘' }, 138→ { key: 'name', symbol: '♔' }, 139→ { key: 'mode', symbol: '♗' }, 140→ { key: ': ', symbol: '⼄' }, // every other header's colon 141→ // ── header values (zodiac) ───────────────────────────────────────────── 142→ { key: 'additive', symbol: '♋' }, 143→ { key: 'lines', symbol: '♈' }, 144→ { key: 'dots', symbol: '♉' }, 145→ { key: 'solid', symbol: '♊' }, 146→ { key: 'alpha', symbol: '♌' }, 147→ // ── built-in variables (the engine's full injected set) ──────────────── 148→ { key: 'bass_att', symbol: '♎' }, 149→ { key: 'mid_att', symbol: '♏' }, 150→ { key: 'treb_att', symbol: '♐' }, 151→ { key: 'vol_att', symbol: '⼉' }, 152→ { key: 'beatphase', symbol: '⼊' }, 153→ { key: 'beatconf', symbol: '⼋' }, 154→ { key: 'bass', symbol: '♑' }, 155→ { key: 'treb', symbol: '♓' }, 156→ { key: 'frame', symbol: '⼌' }, 157→ { key: 'time', symbol: '⼍' }, 158→ { key: 'mid', symbol: '♒' }, 159→ { key: 'vol', symbol: '☽' }, 160→ { key: 'beat', symbol: '◉' }, 161→ { key: 'red', symbol: '☾' }, 162→ { key: 'blue', symbol: '⚑' }, 163→ { key: 'skip', symbol: '⚐' }, 164→ { key: 'green', symbol: '☿' }, 165→ { key: 'aspect', symbol: '⚶' }, 166→ { key: 'chroma', symbol: '⼏' }, 167→ { key: 'dt', symbol: '◐' }, 168→ { key: 'pi', symbol: '◑' }, 169→ { key: 'tau', symbol: '◒' }, 170→ { key: 'bpm', symbol: '⼎' }, 171→ { key: 'hue', symbol: '⋊' }, 172→ { key: 'rad', symbol: '⋅' }, 173→ { key: 'flash', symbol: '∘' }, 174→ { key: 'sv', symbol: '⼐' }, // per-point spectrum level 175→ // ── functions WITH their open paren (a call costs one fewer symbol) ──── 176→ { key: 'smoothstep(', symbol: '⼑' }, 177→ { key: 'gmegabuf(', symbol: '⼒' }, 178→ { key: 'getkbmouse(', symbol: '⼓' }, 179→ { key: 'megabuf(', symbol: '⼔' }, 180→ { key: 'getspec(', symbol: '⼕' }, 181→ { key: 'getosc(', symbol: '⼖' }, 182→ { key: 'gettime(', symbol: '⼗' }, 183→ { key: 'invsqrt(', symbol: '⼘' }, 184→ { key: 'sigmoid(', symbol: '⼙' }, 185→ { key: 'atan2(', symbol: '⼚' }, 186→ { key: 'inrange(', symbol: '⼛' }, 187→ { key: 'log10(', symbol: '⼜' }, 188→ { key: 'floor(', symbol: '⼝' }, 189→ { key: 'above(', symbol: '⼞' }, 190→ { key: 'below(', symbol: '⼟' }, 191→ { key: 'equal(', symbol: '⼠' }, 192→ { key: 'round(', symbol: '⼡' }, 193→ { key: 'clamp(', symbol: '⼢' }, 194→ { key: 'lerp(', symbol: '⼣' }, 195→ { key: 'asin(', symbol: '⼤' }, 196→ { key: 'acos(', symbol: '⼥' }, 197→ { key: 'atan(', symbol: '⼦' }, 198→ { key: 'sign(', symbol: '⼧' }, 199→ { key: 'band(', symbol: '⼨' }, 200→ { key: 'bnot(', symbol: '⼩' }, 201→ { key: 'hsvr(', symbol: '⼪' }, 202→ { key: 'hsvg(', symbol: '⼫' }, 203→ { key: 'hsvb(', symbol: '⼬' }, 204→ { key: 'ceil(', symbol: '⼭' }, 205→ { key: 'frac(', symbol: '⼮' }, 206→ { key: 'pow(', symbol: '⼯' }, 207→ { key: 'exp(', symbol: '⼰' }, 208→ { key: 'log(', symbol: '⼱' }, 209→ { key: 'int(', symbol: '⼲' }, 210→ { key: 'min(', symbol: '⼳' }, 211→ { key: 'max(', symbol: '⼴' }, 212→ { key: 'abs(', symbol: '⼵' }, 213→ { key: 'sin(', symbol: '⼶' }, 214→ { key: 'cos(', symbol: '⼷' }, 215→ { key: 'tan(', symbol: '⼸' }, 216→ { key: 'if(', symbol: '⼹' }, 217→ { key: 'rnd(', symbol: '⼺' }, 218→ { key: 'rand(', symbol: '⼻' }, 219→ { key: 'loop(', symbol: '⼼' }, 220→ // ── functions without paren (for the times they're not calls) ────────── 221→ { key: 'smoothstep', symbol: '⌒' }, 222→ { key: 'gmegabuf', symbol: '✥' }, 223→ { key: 'getkbmouse', symbol: '◶' }, 224→ { key: 'atan2', symbol: '⌗' }, 225→ { key: 'megabuf', symbol: '✤' }, 226→ { key: 'getspec', symbol: '▤' }, 227→ { key: 'getosc', symbol: '▥' }, 228→ { key: 'gettime', symbol: '◷' }, 229→ { key: 'log10', symbol: '㏑' }, 230→ { key: 'invsqrt', symbol: '√' }, 231→ { key: 'inrange', symbol: '⋔' }, 232→ { key: 'sigmoid', symbol: '∿' }, 233→ { key: 'floor', symbol: '⌊' }, 234→ { key: 'above', symbol: '≻' }, 235→ { key: 'below', symbol: '≺' }, 236→ { key: 'asin', symbol: '⌠' }, 237→ { key: 'acos', symbol: '⌡' }, 238→ { key: 'atan', symbol: '⌐' }, 239→ { key: 'ceil', symbol: '⌈' }, 240→ { key: 'sqr', symbol: '□' }, 241→ { key: 'sqrt', symbol: '∛' }, 242→ { key: 'pow', symbol: '⌃' }, 243→ { key: 'exp', symbol: 'ℯ' }, 244→ { key: 'log', symbol: '㏒' }, 245→ { key: 'round', symbol: '◎' }, 246→ { key: 'int', symbol: '⊺' }, 247→ { key: 'frac', symbol: '∵' }, 248→ { key: 'sign', symbol: '±' }, 249→ { key: 'bnot', symbol: '¬' }, 250→ { key: 'band', symbol: '∧' }, 251→ { key: 'bor', symbol: '∨' }, 252→ { key: 'equal', symbol: '≟' }, 253→ { key: 'lerp', symbol: '↝' }, 254→ { key: 'clamp', symbol: '⊜' }, 255→ { key: 'rand', symbol: '⚄' }, 256→ { key: 'rnd', symbol: '⚅' }, 257→ { key: 'sin', symbol: '◠' }, 258→ { key: 'cos', symbol: '◡' }, 259→ { key: 'tan', symbol: '◮' }, 260→ { key: 'abs', symbol: '¦' }, 261→ { key: 'min', symbol: '≪' }, 262→ { key: 'max', symbol: '≫' }, 263→ { key: 'if', symbol: '∴' }, 264→ { key: 'loop', symbol: '↻' }, 265→ // ── near-universal user variables (mined from the preset library) ────── 266→ { key: 'rings', symbol: '⼽' }, 267→ { key: 'spokes', symbol: '⼾' }, 268→ { key: 'travel', symbol: '⼿' }, 269→ { key: 'bars', symbol: '⽀' }, 270→ { key: 'segs', symbol: '⽁' }, 271→ { key: 'ring', symbol: '⽂' }, 272→ { key: 'spoke', symbol: '⽃' }, 273→ { key: 'fade', symbol: '⽄' }, 274→ { key: 'size', symbol: '⽅' }, 275→ { key: 'rot', symbol: '⽆' }, 276→ { key: 'seg', symbol: '⽇' }, 277→ { key: 'col', symbol: '⽈' }, 278→ { key: 'lum', symbol: '⽉' }, 279→ { key: 'amp', symbol: '⽊' }, 280→ { key: 'spin', symbol: '⽋' }, 281→ { key: 'drift', symbol: '⽌' }, 282→ { key: 'orbit', symbol: '⽍' }, 283→ { key: 'pulse', symbol: '⽎' }, 284→ { key: 'glow', symbol: '⽏' }, 285→ { key: 'phase', symbol: '⽐' }, 286→ { key: 'wobble', symbol: '⽑' }, 287→ { key: 'angle', symbol: '⽒' }, 288→ // ── structural idioms (mined — the top repeated shapes) ──────────────── 289→ { key: ' * 0.', symbol: '⽓' }, 290→ { key: ' * (0.', symbol: '⽔' }, 291→ { key: '(0.', symbol: '⽕' }, 292→ { key: ') * ', symbol: '⽖' }, 293→ { key: 't * ', symbol: '⽗' }, 294→ { key: '5 + ', symbol: '⽘' }, 295→ { key: ' + dt * ', symbol: '⽙' }, 296→ { key: '(a)', symbol: '⽚' }, 297→ { key: 'hue = hue + dt * ', symbol: '⽛' }, 298→ { key: 't = t + dt * ', symbol: '⽜' }, 299→ { key: ' / aspect', symbol: '⽝' }, 300→ // ── number literals (mined from the library) ─────────────────────────── 301→ { key: '6.28318', symbol: '⌇' }, // 2π, written out in most scripts 302→ { key: '3.14159', symbol: '⽣' }, 303→ { key: '3.1415', symbol: '⽤' }, 304→ { key: '0.55', symbol: '§' }, 305→ { key: '0.45', symbol: '¶' }, 306→ { key: '0.35', symbol: '※' }, 307→ { key: '0.25', symbol: '•' }, 308→ { key: '0.85', symbol: '⊢' }, 309→ { key: '0.75', symbol: '⽥' }, 310→ { key: '0.65', symbol: '⽦' }, 311→ { key: '0.95', symbol: '⽧' }, 312→ { key: '0.15', symbol: '⽨' }, 313→ { key: '0.12', symbol: '⽩' }, 314→ { key: '0.22', symbol: '⽪' }, 315→ { key: '0.42', symbol: '⽫' }, 316→ { key: '0.05', symbol: '⽬' }, 317→ { key: '0.06', symbol: '⽭' }, 318→ { key: '0.04', symbol: '⽮' }, 319→ { key: '0.07', symbol: '⽯' }, 320→ { key: '0.08', symbol: '⽰' }, 321→ { key: '0.96', symbol: '⽱' }, 322→ { key: '0.97', symbol: '⽲' }, 323→ { key: '2.09', symbol: '⽳' }, 324→ { key: '4.19', symbol: '⽴' }, 325→ { key: '0.5', symbol: '†' }, 326→ { key: '0.3', symbol: '‡' }, 327→ { key: '0.4', symbol: '⊤' }, 328→ { key: '0.7', symbol: '⊥' }, 329→ { key: '0.6', symbol: '⊣' }, 330→ { key: '0.9', symbol: '⽵' }, 331→ { key: '0.8', symbol: '⽶' }, 332→ { key: '0.2', symbol: '⽷' }, 333→ { key: '0.1', symbol: '⽸' }, 334→ { key: '1.3', symbol: '⋈' }, 335→ { key: '1.2', symbol: '⋉' }, 336→ { key: '1.0', symbol: '⽹' }, 337→ { key: '100', symbol: '⽺' }, 338→ { key: '2.5', symbol: '⽻' }, 339→ { key: '0.11', symbol: '⽼' }, 340→ { key: '0.18', symbol: '⽽' }, 341→ { key: '0.92', symbol: '⽾' }, 342→ { key: '0.99', symbol: '⽿' }, 343→ { key: '1.5', symbol: '⾀' }, 344→ { key: '0.98', symbol: '⾁' }, 345→ { key: '0.13', symbol: '⾂' }, 346→ { key: '0.14', symbol: '⾃' }, 347→ { key: '0.16', symbol: '⾄' }, 348→ { key: '0.17', symbol: '⾅' }, 349→ { key: '0.19', symbol: '⾆' }, 350→ { key: '0.21', symbol: '⾇' }, 351→ { key: '0.28', symbol: '⾈' }, 352→ { key: '0.31', symbol: '⾉' }, 353→ { key: '0.32', symbol: '⾊' }, 354→ { key: '0.33', symbol: '⾋' }, 355→ { key: '0.38', symbol: '⾌' }, 356→ { key: '0.44', symbol: '⾍' }, 357→ { key: '0.48', symbol: '⾎' }, 358→ { key: '0.52', symbol: '⾏' }, 359→ { key: '0.58', symbol: '⾐' }, 360→ { key: '0.62', symbol: '⾑' }, 361→ { key: '0.72', symbol: '⾒' }, 362→ { key: '0.78', symbol: '⾓' }, 363→ { key: '0.82', symbol: '⾔' }, 364→ { key: '0.88', symbol: '⾕' }, 365→ { key: '1.1', symbol: '⾖' }, 366→ { key: '1.4', symbol: '⾗' }, 367→ { key: '1.6', symbol: '⾘' }, 368→ { key: '1.7', symbol: '⾙' }, 369→ { key: '1.8', symbol: '⾚' }, 370→ { key: '2.0', symbol: '⾛' }, 371→ { key: '2.2', symbol: '⾜' }, 372→ { key: '2.4', symbol: '⾝' }, 373→ { key: '2.6', symbol: '⾞' }, 374→ { key: '3.0', symbol: '⾟' }, 375→ { key: '3.5', symbol: '⾠' }, 376→ { key: '0.87', symbol: '⾡' }, 377→ { key: '0.24', symbol: '⾢' }, 378→ { key: '0.26', symbol: '⾣' }, 379→ { key: '0.27', symbol: '⾤' }, 380→ { key: '0.36', symbol: '⾥' }, 381→ // ── spaced operators (arrows) ────────────────────────────────────────── 382→ { key: ' = ', symbol: '←' }, 383→ { key: ' + ', symbol: '→' }, 384→ { key: ' * ', symbol: '↑' }, 385→ { key: ' - ', symbol: '↓' }, 386→ { key: ' / ', symbol: '∙' }, 387→ { key: ', ', symbol: '⇄' }, 388→ // ── statement openers (double arrows) ────────────────────────────────── 389→ { key: 'green = ', symbol: '⇩' }, 390→ { key: 'blue = ', symbol: '⇪' }, 391→ { key: 'x = ', symbol: '⇦' }, 392→ { key: 'y = ', symbol: '⇨' }, 393→ { key: 'red = ', symbol: '⇧' }, 394→ { key: 'hue = ', symbol: '⇱' }, 395→ { key: 't = ', symbol: '⇲' }, 396→ { key: 'n = ', symbol: '⇴' }, 397→] 398→ 399→/** Noise chars platforms inject into copies (VS16, zero-widths). The 400→ * encoder NEVER emits these raw — a script that legitimately contains one 401→ * gets a dedicated symbol — so the decoder can strip every raw occurrence 402→ * as injected mangling. */ 403→const NOISE_SYMBOLS = new Map([ 404→ ['\uFE0E', '⌸'], 405→ ['\uFE0F', '⌹'], 406→ ['\u200B', '⌺'], 407→ ['\u200C', '⌻'], 408→ ['\u200D', '⌼'], 409→ ['\u2060', '⌽'], 410→ ['\uFEFF', '⌾'], 411→]) 412→const NOISE_CHAR_FROM_SYMBOL = new Map( 413→ [...NOISE_SYMBOLS.entries()].map(([c, s]) => [s, c]), 414→) 415→ 416→// ── derived tables (built once, self-checked by construction) ───────────── 417→ 418→/** Longest key first, so greedy matching always prefers the longest hit. */ 419→const DICT_SORTED = [...DICT].sort((a, b) => b.key.length - a.key.length) 420→ 421→/** Entries grouped by first char, longest first — the encode lookup. */ 422→const BY_FIRST_CHAR = new Map() 423→for (const entry of DICT_SORTED) { 424→ const list = BY_FIRST_CHAR.get(entry.key[0]) 425→ if (list) list.push(entry) 426→ else BY_FIRST_CHAR.set(entry.key[0], [entry]) 427→} 428→ 429→/** Symbol → raw text (decode lookup). Throws on duplicate symbols — a 430→ * broken table must fail loudly at import, never corrupt a share string. */ 431→const SYMBOL_TO_TEXT = new Map() 432→for (const { key, symbol } of DICT) { 433→ if (SYMBOL_TO_TEXT.has(symbol)) throw new Error(`share-codec: duplicate dictionary symbol ${symbol}`) 434→ SYMBOL_TO_TEXT.set(symbol, key) 435→} 436→ 437→const ALL_SYMBOLS = new Set(DICT.map((e) => e.symbol)) 438→for (const s of NOISE_SYMBOLS.values()) ALL_SYMBOLS.add(s) 439→ 440→/** 441→ * The FROZEN bitmap list — every dictionary + noise symbol, sorted by code 442→ * point. A ▼ header says "these of the list are in this string's alphabet" 443→ * as a bitmap, 14 bits per digit character. This array is part of the ▼ 444→ * wire format: changing it breaks every ▼ string ever encoded (see the 445→ * DICT compatibility contract above). 446→ */ 447→const BITMAP_LIST: string[] = [...ALL_SYMBOLS].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) 448→const BITMAP_INDEX = new Map(BITMAP_LIST.map((s, i) => [s, i])) 449→const DICT_BITMAP_DIGITS = Math.ceil(BITMAP_LIST.length / 14) // 14 bits per digit 450→const ASCII_BITMAP_DIGITS = Math.ceil(128 / 14) 451→ 452→/** 453→ * Every character that can never appear raw in a body — emitted by the 454→ * encoder only with a "\" escape in front (pair form) or folded into the 455→ * bitmaps (base form). Covers the dictionary symbols, both scheme markers, 456→ * the separator, the checksum alphabet (①–⑯), the control-picture range 457→ * (␀–␡) and the invisible characters chat platforms inject. 458→ */ 459→const RESERVED = new Set([ESCAPE, SCHEME_V1, SCHEME_V2, SEP]) 460→for (const s of ALL_SYMBOLS) RESERVED.add(s) 461→for (let c = 0x2460; c <= 0x246f; c++) RESERVED.add(String.fromCharCode(c)) // ①–⑯ 462→for (let c = 0x2400; c <= 0x2421; c++) RESERVED.add(String.fromCharCode(c)) // ␀–␡ 463→for (const c of ['\uFE0E', '\uFE0F', '\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF']) { 464→ RESERVED.add(c) 465→} 466→ 467→const PLATFORM_NOISE = /[\uFE0E\uFE0F\u200B\u200C\u200D\u2060\uFEFF]/g 468→ 469→/** Whitespace or platform noise — safe to shave off the string's edges. */ 470→function isEdgeTrimable(c: string): boolean { 471→ return c === ' ' || c === '\n' || c === '\r' || c === '\t' || PLATFORM_NOISE.test(c) 472→} 473→ 474→// ── pair packing (shared by the ▼ fallback form and legacy ▲ strings) ────── 475→ 476→/** 477→ * Two adjacent characters from this 64-char alphabet fold into ONE code 478→ * point at PAIR_BASE + 64·a + b (a common-CJK block — assigned since 479→ * Unicode 1.1, text presentation, renders everywhere, copy/paste-safe). 480→ * The block is reserved, so CJK that legitimately appears in a script is 481→ * escaped and still round-trips. 482→ */ 483→const PAIR_ALPHABET = 484→ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.' 485→const PAIR_BASE = 0x4e00 486→const PAIR_END = PAIR_BASE + PAIR_ALPHABET.length * PAIR_ALPHABET.length - 1 // 0x5dff 487→const PAIR_INDEX = new Map() 488→for (let i = 0; i < PAIR_ALPHABET.length; i++) PAIR_INDEX.set(PAIR_ALPHABET[i], i) 489→for (let c = PAIR_BASE; c <= PAIR_END; c++) RESERVED.add(String.fromCharCode(c)) 490→ 491→// ── big-base packing ─────────────────────────────────────────────────────── 492→ 493→/** 494→ * The digit alphabet for the base form: CJK Unified Ideographs (20,992) + 495→ * Hangul syllables (11,172) = 32,164 digits — every one assigned, text 496→ * presentation, in every CJK-capable font, and copy/paste-safe. 14.98 497→ * bits per character, vs 5.95 for classic base-62. 498→ */ 499→const DIGIT_BLOCKS: Array<[number, number]> = [ 500→ [0x4e00, 0x9fff], // CJK Unified Ideographs 501→ [0xac00, 0xd7a3], // Hangul syllables 502→] 503→const DIGIT_BLOCK0_SIZE = DIGIT_BLOCKS[0][1] - DIGIT_BLOCKS[0][0] + 1 504→const BIG_BASE = DIGIT_BLOCK0_SIZE + (DIGIT_BLOCKS[1][1] - DIGIT_BLOCKS[1][0] + 1) // 32164 505→const BIG_BASE_N = BigInt(BIG_BASE) 506→const BITS_PER_DIGIT = 14 // floor(log2(32164)) -- bitmap digits carry 14 bits 507→ 508→function digitChar(d: number): string { 509→ return d < DIGIT_BLOCK0_SIZE 510→ ? String.fromCharCode(DIGIT_BLOCKS[0][0] + d) 511→ : String.fromCharCode(DIGIT_BLOCKS[1][0] + (d - DIGIT_BLOCK0_SIZE)) 512→} 513→ 514→/** Digit char → value, or -1 when the char isn't a digit. */ 515→function digitValue(c: string): number { 516→ const cc = c.charCodeAt(0) 517→ if (cc >= DIGIT_BLOCKS[0][0] && cc <= DIGIT_BLOCKS[0][1]) return cc - DIGIT_BLOCKS[0][0] 518→ if (cc >= DIGIT_BLOCKS[1][0] && cc <= DIGIT_BLOCKS[1][1]) return cc - DIGIT_BLOCKS[1][0] + DIGIT_BLOCK0_SIZE 519→ return -1 520→} 521→ 522→/** Bits → exactly `digits` digit chars (left-padded with digit 0). */ 523→function bitsToDigits(bits: boolean[], digits: number): string { 524→ let value = 0n 525→ for (const b of bits) value = value * 2n + (b ? 1n : 0n) 526→ const ds: number[] = [] 527→ let v = value 528→ while (v > 0n) { 529→ ds.push(Number(v % BIG_BASE_N)) 530→ v /= BIG_BASE_N 531→ } 532→ while (ds.length < digits) ds.push(0) 533→ if (ds.length > digits) throw new Error('share-codec: bitmap overflow') 534→ let out = '' 535→ for (let k = ds.length - 1; k >= 0; k--) out += digitChar(ds[k]) 536→ return out 537→} 538→ 539→/** The first `count` digit chars of `s` → bits (big-endian, `bits` long). */ 540→function digitsToBits(s: string, count: number, bits: number): boolean[] { 541→ let value = 0n 542→ for (let i = 0; i < count; i++) { 543→ const d = digitValue(s[i]) 544→ if (d < 0) throw new Error('share-codec: bad bitmap digit') 545→ value = value * BIG_BASE_N + BigInt(d) 546→ } 547→ const out: boolean[] = [] 548→ for (let i = bits - 1; i >= 0; i--) out.push((value >> BigInt(i)) & 1n ? true : false) 549→ return out 550→} 551→ 552→/** 553→ * Largest body (in symbols) the big-base packer will accept — packing is 554→ * O(n²) in BigInt arithmetic, and 16,000 symbols packs in ~150 ms while a 555→ * 50,000-symbol body would take seconds. Longer scripts fall back to the 556→ * linear pair form (still ~55% shorter). 557→ */ 558→const PACK_SYMBOL_LIMIT = 16_000 559→ 560→/** 561→ * Pack a dictionary body into the base form: 562→ * 563→ * 〠 564→ * 565→ * The payload is the whole body as one big number (a leading sentinel 566→ * symbol — the alphabet's last — guarantees leading-alphabet-symbol zeros 567→ * survive). The header rebuilds the body's alphabet for the decoder: which 568→ * frozen dictionary symbols are used (bitmap), which ASCII chars are used 569→ * (bitmap), and any remaining non-ASCII chars verbatim. 570→ */ 571→function packBase(body: string): string | null { 572→ const n = body.length 573→ if (n < 2 || n > PACK_SYMBOL_LIMIT) return null 574→ 575→ const used = new Set() 576→ for (let i = 0; i < n; i++) used.add(body[i]) 577→ const alphabet = [...used].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) 578→ const m = alphabet.length 579→ if (m < 2) return null 580→ 581→ const indexOf = new Map() 582→ alphabet.forEach((s, idx) => indexOf.set(s, idx)) 583→ 584→ // value = sentinel·m^n + body as a base-m number 585→ let value = BigInt(m - 1) 586→ for (let i = 0; i < n; i++) value = value * BigInt(m) + BigInt(indexOf.get(body[i])!) 587→ 588→ let payload = '' 589→ const ds: number[] = [] 590→ let v = value 591→ while (v > 0n) { 592→ ds.push(Number(v % BIG_BASE_N)) 593→ v /= BIG_BASE_N 594→ } 595→ for (let k = ds.length - 1; k >= 0; k--) payload += digitChar(ds[k]) 596→ 597→ // header bitmaps 598→ const dictBits: boolean[] = [] 599→ for (const s of BITMAP_LIST) dictBits.push(used.has(s)) 600→ const asciiBits: boolean[] = [] 601→ for (let c = 0; c < 128; c++) asciiBits.push(used.has(String.fromCharCode(c))) 602→ let tail = '' 603→ for (const s of alphabet) { 604→ const cc = s.charCodeAt(0) 605→ if (cc >= 128 && !BITMAP_INDEX.has(s)) tail += s 606→ } 607→ 608→ return ( 609→ payload + SEP + bitsToDigits(dictBits, DICT_BITMAP_DIGITS) + bitsToDigits(asciiBits, ASCII_BITMAP_DIGITS) + tail 610→ ) 611→} 612→ 613→/** Inverse of packBase — returns null on any structural violation. */ 614→function unpackBase(packed: string): string | null { 615→ // first bare SEP splits payload | header (payload digits are CJK/Hangul, 616→ // so no digit can ever be mistaken for the separator) 617→ let sepIdx = -1 618→ for (let i = 0; i < packed.length; i++) { 619→ if (packed[i] === SEP) { 620→ sepIdx = i 621→ break 622→ } 623→ } 624→ if (sepIdx < 1 || sepIdx === packed.length - 1) return null 625→ 626→ let value = 0n 627→ for (let i = 0; i < sepIdx; i++) { 628→ const d = digitValue(packed[i]) 629→ if (d < 0) return null 630→ value = value * BIG_BASE_N + BigInt(d) 631→ } 632→ if (value <= 0n) return null 633→ 634→ const header = packed.slice(sepIdx + 1) 635→ if (header.length < DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS) return null 636→ let dictBits: boolean[] 637→ let asciiBits: boolean[] 638→ try { 639→ dictBits = digitsToBits(header, DICT_BITMAP_DIGITS, BITMAP_LIST.length) 640→ asciiBits = digitsToBits(header.slice(DICT_BITMAP_DIGITS), ASCII_BITMAP_DIGITS, 128) 641→ } catch { 642→ return null 643→ } 644→ const tail = header.slice(DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS) 645→ 646→ // rebuild the alphabet: frozen symbols + ASCII + verbatim tail, sorted 647→ const alphabet: string[] = [] 648→ dictBits.forEach((on, i) => { 649→ if (on) alphabet.push(BITMAP_LIST[i]) 650→ }) 651→ asciiBits.forEach((on, c) => { 652→ if (on) alphabet.push(String.fromCharCode(c)) 653→ }) 654→ const seen = new Set(alphabet) 655→ for (let i = 0; i < tail.length; i++) { 656→ const c = tail[i] 657→ if (seen.has(c)) return null // tail must be deduped 658→ seen.add(c) 659→ alphabet.push(c) 660→ } 661→ alphabet.sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) 662→ 663→ const m = alphabet.length 664→ if (m < 2) return null 665→ const M = BigInt(m) 666→ const idxs: number[] = [] 667→ while (value > 0n) { 668→ idxs.push(Number(value % M)) 669→ value /= M 670→ } 671→ let s = '' 672→ for (let k = idxs.length - 1; k >= 0; k--) { 673→ const idx = idxs[k] 674→ if (idx >= m) return null 675→ s += alphabet[idx] 676→ } 677→ if (s.length < 1) return null 678→ return s.slice(1) // strip the sentinel 679→} 680→ 681→// ── checksum ────────────────────────────────────────────────────────────── 682→ 683→/** FNV-1a 32-bit over the UTF-16 code units — fast, tiny, plenty here. */ 684→function fnv1a(s: string): number { 685→ let h = 0x811c9dc5 686→ for (let i = 0; i < s.length; i++) { 687→ h ^= s.charCodeAt(i) 688→ h = Math.imul(h, 0x01000193) >>> 0 689→ } 690→ return h >>> 0 691→} 692→ 693→/** Encode a 32-bit digest as 8 "circled digit" hex chars (① = 0 … ⑯ = f). */ 694→function checkDigits(value: number): string { 695→ let out = '' 696→ for (let shift = 28; shift >= 0; shift -= 4) { 697→ out += String.fromCharCode(0x2460 + ((value >>> shift) & 0xf)) 698→ } 699→ return out 700→} 701→ 702→function isCheckDigit(c: string): boolean { 703→ const cc = c.charCodeAt(0) 704→ return cc >= 0x2460 && cc <= 0x246f 705→} 706→ 707→// ── name label ──────────────────────────────────────────────────────────── 708→ 709→/** Pull the `name:` header out of a script (before the first [section]). */ 710→function extractScriptName(script: string): string { 711→ for (const line of script.split('\n')) { 712→ const t = line.trim() 713→ if (t.startsWith('[')) break 714→ const m = /^name\s*:\s*(.*)$/i.exec(t) 715→ if (m) { 716→ let v = m[1].trim() 717→ if ( 718→ v.length >= 2 && 719→ ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) 720→ ) { 721→ v = v.slice(1, -1).trim() 722→ } 723→ if (v) return v 724→ } 725→ } 726→ return '' 727→} 728→ 729→/** Make the label safe for the `Name:[…]` wrapper (no : [ ] or newlines). */ 730→function sanitizeShareName(raw: string): string { 731→ const cleaned = raw 732→ .replace(/[\[\]:\r\n\t]+/g, ' ') 733→ .replace(/\s+/g, ' ') 734→ .trim() 735→ .slice(0, 48) 736→ .trim() 737→ return cleaned || 'OVE' 738→} 739→ 740→// ── encode ──────────────────────────────────────────────────────────────── 741→ 742→/** Substitute the dictionary into the script text (escaping as needed). */ 743→function encodeDictBody(script: string): string { 744→ let out = '' 745→ let i = 0 746→ const n = script.length 747→ while (i < n) { 748→ const c = script[i] 749→ // legitimate noise chars → their dedicated symbols (never raw) 750→ const noiseSym = NOISE_SYMBOLS.get(c) 751→ if (noiseSym !== undefined) { 752→ out += noiseSym 753→ i++ 754→ continue 755→ } 756→ // reserved / escape char → escaped literal 757→ if (c === ESCAPE || RESERVED.has(c)) { 758→ out += ESCAPE + c 759→ i++ 760→ continue 761→ } 762→ // dictionary match (longest first) — also folds \n \r \t into symbols 763→ const entries = BY_FIRST_CHAR.get(c) 764→ if (entries) { 765→ let matched = false 766→ for (const e of entries) { 767→ if (script.startsWith(e.key, i)) { 768→ out += e.symbol 769→ i += e.key.length 770→ matched = true 771→ break 772→ } 773→ } 774→ if (matched) continue 775→ } 776→ // other control characters → Unicode control pictures (copy-safe) 777→ const cc = c.charCodeAt(0) 778→ if (cc < 0x20 || cc === 0x7f) { 779→ out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc) 780→ i++ 781→ continue 782→ } 783→ out += c 784→ i++ 785→ } 786→ return out 787→} 788→ 789→/** Dictionary + pair packing interleaved (the fallback form, v7.7-style). */ 790→function encodePairBody(script: string): string { 791→ let out = '' 792→ let i = 0 793→ const n = script.length 794→ while (i < n) { 795→ const c = script[i] 796→ const noiseSym = NOISE_SYMBOLS.get(c) 797→ if (noiseSym !== undefined) { 798→ out += noiseSym 799→ i++ 800→ continue 801→ } 802→ if (c === ESCAPE || RESERVED.has(c)) { 803→ out += ESCAPE + c 804→ i++ 805→ continue 806→ } 807→ const entries = BY_FIRST_CHAR.get(c) 808→ if (entries) { 809→ let matched = false 810→ for (const e of entries) { 811→ if (script.startsWith(e.key, i)) { 812→ out += e.symbol 813→ i += e.key.length 814→ matched = true 815→ break 816→ } 817→ } 818→ if (matched) continue 819→ } 820→ const cc = c.charCodeAt(0) 821→ if (cc < 0x20 || cc === 0x7f) { 822→ out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc) 823→ i++ 824→ continue 825→ } 826→ // pair packing — two adjacent [0-9a-zA-Z_.] chars → one CJK code point 827→ const ai = PAIR_INDEX.get(c) 828→ if (ai !== undefined) { 829→ const bi = PAIR_INDEX.get(script[i + 1]) 830→ if (bi !== undefined) { 831→ out += String.fromCharCode(PAIR_BASE + ai * PAIR_ALPHABET.length + bi) 832→ i += 2 833→ continue 834→ } 835→ } 836→ out += c 837→ i++ 838→ } 839→ return out 840→} 841→ 842→export interface ScriptHash { 843→ /** The full share string: `Name:[▼…①①①…]`. */ 844→ hash: string 845→ /** The label used in the prefix (sanitized). */ 846→ name: string 847→ /** script.length (UTF-16 units) — for the stats line. */ 848→ scriptLength: number 849→ /** hash.length — for the stats line. */ 850→ hashLength: number 851→} 852→ 853→/** 854→ * Encode a script into its share string. Never throws, never validates the 855→ * script — sharing a script that doesn't compile is a feature (ask for help, 856→ * get it debugged). `name` overrides the label; by default the `name:` 857→ * header is used, falling back to "OVE". 858→ */ 859→export function encodeScriptHash(script: string, name?: string): ScriptHash { 860→ const label = sanitizeShareName(name ?? extractScriptName(script)) 861→ const dictBody = encodeDictBody(script) 862→ const packed = packBase(dictBody) 863→ let body: string 864→ if (packed !== null) { 865→ const pairBody = encodePairBody(script) 866→ body = packed.length < pairBody.length ? packed : pairBody 867→ } else { 868→ body = encodePairBody(script) 869→ } 870→ const hash = `${label}:[${SCHEME_V2}${body}${checkDigits(fnv1a(script))}]` 871→ return { hash, name: label, scriptLength: script.length, hashLength: hash.length } 872→} 873→ 874→// ── decode ──────────────────────────────────────────────────────────────── 875→ 876→/** Expand a dictionary body back into the original text. */ 877→function decodeDictBody(body: string): string { 878→ let out = '' 879→ for (let i = 0; i < body.length; i++) { 880→ const c = body[i] 881→ if (c === ESCAPE) { 882→ const next = body[i + 1] 883→ if (next !== undefined) { 884→ out += next 885→ i++ 886→ } else { 887→ out += c // trailing lone escape -- be lenient 888→ } 889→ continue 890→ } 891→ const cc = c.charCodeAt(0) 892→ // control pictures → the control chars they stand for 893→ if (cc >= 0x2400 && cc <= 0x2420) { 894→ out += String.fromCharCode(cc - 0x2400) 895→ continue 896→ } 897→ if (cc === 0x2421) { 898→ out += '\u007f' 899→ continue 900→ } 901→ // dictionary symbols → raw text 902→ const expansion = SYMBOL_TO_TEXT.get(c) 903→ if (expansion !== undefined) { 904→ out += expansion 905→ continue 906→ } 907→ // legitimate noise symbols → their original chars 908→ const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c) 909→ if (noiseChar !== undefined) { 910→ out += noiseChar 911→ continue 912→ } 913→ out += c 914→ } 915→ return out 916→} 917→ 918→/** Expand a pair-form body (dictionary + pair slots) into the script. */ 919→function decodePairBody(body: string): string { 920→ let out = '' 921→ for (let i = 0; i < body.length; i++) { 922→ const c = body[i] 923→ if (c === ESCAPE) { 924→ const next = body[i + 1] 925→ if (next !== undefined) { 926→ out += next 927→ i++ 928→ } else { 929→ out += c 930→ } 931→ continue 932→ } 933→ const cc = c.charCodeAt(0) 934→ if (cc >= 0x2400 && cc <= 0x2420) { 935→ out += String.fromCharCode(cc - 0x2400) 936→ continue 937→ } 938→ if (cc === 0x2421) { 939→ out += '\u007f' 940→ continue 941→ } 942→ const expansion = SYMBOL_TO_TEXT.get(c) 943→ if (expansion !== undefined) { 944→ out += expansion 945→ continue 946→ } 947→ const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c) 948→ if (noiseChar !== undefined) { 949→ out += noiseChar 950→ continue 951→ } 952→ if (cc >= PAIR_BASE && cc <= PAIR_END) { 953→ const k = cc - PAIR_BASE 954→ out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length] 955→ continue 956→ } 957→ out += c 958→ } 959→ return out 960→} 961→ 962→// ── legacy ▲ support (v7.7 strings — decode only) ────────────────────────── 963→ 964→const LEGACY_DICT: DictEntry[] = [ 965→ { key: '\n', symbol: '♤' }, 966→ { key: ' ', symbol: '♦' }, 967→ { key: 'beats', symbol: '♡' }, 968→ { key: 'beatage', symbol: '♠' }, 969→ { key: '\t', symbol: '♣' }, 970→ { key: ' ', symbol: '▣' }, 971→ { key: ' ', symbol: '·' }, 972→ { key: '\r', symbol: '⏎' }, 973→ { key: '[init]', symbol: '♩' }, 974→ { key: '[frame]', symbol: '♪' }, 975→ { key: '[beat]', symbol: '♫' }, 976→ { key: '[point]', symbol: '♬' }, 977→ { key: '# ', symbol: '♯' }, 978→ { key: '#', symbol: '♭' }, 979→ { key: '//', symbol: '♮' }, 980→ { key: 'name', symbol: '♔' }, 981→ { key: 'author', symbol: '♕' }, 982→ { key: 'description', symbol: '♖' }, 983→ { key: 'mode', symbol: '♗' }, 984→ { key: 'blend', symbol: '♘' }, 985→ { key: 'persist', symbol: '♙' }, 986→ { key: 'linewidth', symbol: '♚' }, 987→ { key: 'ysign', symbol: '♛' }, 988→ { key: 'lines', symbol: '♈' }, 989→ { key: 'dots', symbol: '♉' }, 990→ { key: 'solid', symbol: '♊' }, 991→ { key: 'additive', symbol: '♋' }, 992→ { key: 'alpha', symbol: '♌' }, 993→ { key: ' = ', symbol: '←' }, 994→ { key: ' + ', symbol: '→' }, 995→ { key: ' * ', symbol: '↑' }, 996→ { key: ' - ', symbol: '↓' }, 997→ { key: ', ', symbol: '⇄' }, 998→ { key: 'x = ', symbol: '⇦' }, 999→ { key: 'y = ', symbol: '⇨' }, 1000→ { key: 'red = ', symbol: '⇧' }, 1001→ { key: 'green = ', symbol: '⇩' }, 1002→ { key: 'blue = ', symbol: '⇪' }, 1003→ { key: 'hue = ', symbol: '⇱' }, 1004→ { key: 't = ', symbol: '⇲' }, 1005→ { key: 'n = ', symbol: '⇴' }, 1006→ { key: 'bass_att', symbol: '♎' }, 1007→ { key: 'mid_att', symbol: '♏' }, 1008→ { key: 'treb_att', symbol: '♐' }, 1009→ { key: 'bass', symbol: '♑' }, 1010→ { key: 'mid', symbol: '♒' }, 1011→ { key: 'treb', symbol: '♓' }, 1012→ { key: 'vol', symbol: '☽' }, 1013→ { key: 'beat', symbol: '◉' }, 1014→ { key: 'red', symbol: '☾' }, 1015→ { key: 'green', symbol: '☿' }, 1016→ { key: 'blue', symbol: '⚑' }, 1017→ { key: 'skip', symbol: '⚐' }, 1018→ { key: 'aspect', symbol: '⚶' }, 1019→ { key: 'dt', symbol: '◐' }, 1020→ { key: 'pi', symbol: '◑' }, 1021→ { key: 'tau', symbol: '◒' }, 1022→ { key: 'smoothstep', symbol: '⌒' }, 1023→ { key: 'gmegabuf', symbol: '✥' }, 1024→ { key: 'getkbmouse', symbol: '◶' }, 1025→ { key: 'megabuf', symbol: '✤' }, 1026→ { key: 'getspec', symbol: '▤' }, 1027→ { key: 'getosc', symbol: '▥' }, 1028→ { key: 'gettime', symbol: '◷' }, 1029→ { key: 'atan2', symbol: '⌗' }, 1030→ { key: 'log10', symbol: '㏑' }, 1031→ { key: 'invsqrt', symbol: '√' }, 1032→ { key: 'sigmoid', symbol: '∿' }, 1033→ { key: 'inrange', symbol: '⋔' }, 1034→ { key: 'hsvr', symbol: '⬥' }, 1035→ { key: 'hsvg', symbol: '⬦' }, 1036→ { key: 'hsvb', symbol: '⬧' }, 1037→ { key: 'above', symbol: '≻' }, 1038→ { key: 'below', symbol: '≺' }, 1039→ { key: 'asin', symbol: '⌠' }, 1040→ { key: 'acos', symbol: '⌡' }, 1041→ { key: 'atan', symbol: '⌐' }, 1042→ { key: 'ceil', symbol: '⌈' }, 1043→ { key: 'floor', symbol: '⌊' }, 1044→ { key: 'sqr', symbol: '□' }, 1045→ { key: 'sqrt', symbol: '∛' }, 1046→ { key: 'pow', symbol: '⌃' }, 1047→ { key: 'exp', symbol: 'ℯ' }, 1048→ { key: 'log', symbol: '㏒' }, 1049→ { key: 'round', symbol: '◎' }, 1050→ { key: 'int', symbol: '⊺' }, 1051→ { key: 'frac', symbol: '∵' }, 1052→ { key: 'sign', symbol: '±' }, 1053→ { key: 'bnot', symbol: '¬' }, 1054→ { key: 'band', symbol: '∧' }, 1055→ { key: 'bor', symbol: '∨' }, 1056→ { key: 'equal', symbol: '≟' }, 1057→ { key: 'lerp', symbol: '↝' }, 1058→ { key: 'clamp', symbol: '⊜' }, 1059→ { key: 'rand', symbol: '⚄' }, 1060→ { key: 'rnd', symbol: '⚅' }, 1061→ { key: 'sin', symbol: '◠' }, 1062→ { key: 'cos', symbol: '◡' }, 1063→ { key: 'tan', symbol: '◮' }, 1064→ { key: 'abs', symbol: '¦' }, 1065→ { key: 'min', symbol: '≪' }, 1066→ { key: 'max', symbol: '≫' }, 1067→ { key: 'if', symbol: '∴' }, 1068→ { key: 'loop', symbol: '↻' }, 1069→ { key: '6.28318', symbol: '⌇' }, 1070→ { key: '0.5', symbol: '†' }, 1071→ { key: '0.3', symbol: '‡' }, 1072→ { key: '0.25', symbol: '•' }, 1073→ { key: '0.55', symbol: '§' }, 1074→ { key: '0.45', symbol: '¶' }, 1075→ { key: '0.35', symbol: '※' }, 1076→ { key: '0.4', symbol: '⊤' }, 1077→ { key: '0.7', symbol: '⊥' }, 1078→ { key: '0.85', symbol: '⊢' }, 1079→ { key: '0.6', symbol: '⊣' }, 1080→ { key: '1.3', symbol: '⋈' }, 1081→ { key: '1.2', symbol: '⋉' }, 1082→ { key: ' / ', symbol: '∙' }, 1083→ { key: 'hue', symbol: '⋊' }, 1084→ { key: 'rad', symbol: '⋅' }, 1085→ { key: 'flash', symbol: '∘' }, 1086→] 1087→ 1088→const LEGACY_SYMBOL_TO_TEXT = new Map() 1089→for (const { key, symbol } of LEGACY_DICT) LEGACY_SYMBOL_TO_TEXT.set(symbol, key) 1090→for (const [c, s] of NOISE_SYMBOLS) LEGACY_SYMBOL_TO_TEXT.set(s, c) 1091→ 1092→/** Expand a v7.7 ▲ body. */ 1093→function decodeLegacyBody(body: string): string { 1094→ let out = '' 1095→ for (let i = 0; i < body.length; i++) { 1096→ const c = body[i] 1097→ if (c === ESCAPE) { 1098→ const next = body[i + 1] 1099→ if (next !== undefined) { 1100→ out += next 1101→ i++ 1102→ } else { 1103→ out += c 1104→ } 1105→ continue 1106→ } 1107→ const cc = c.charCodeAt(0) 1108→ if (cc >= 0x2400 && cc <= 0x2420) { 1109→ out += String.fromCharCode(cc - 0x2400) 1110→ continue 1111→ } 1112→ if (cc === 0x2421) { 1113→ out += '\u007f' 1114→ continue 1115→ } 1116→ const expansion = LEGACY_SYMBOL_TO_TEXT.get(c) 1117→ if (expansion !== undefined) { 1118→ out += expansion 1119→ continue 1120→ } 1121→ if (cc >= PAIR_BASE && cc <= PAIR_END) { 1122→ const k = cc - PAIR_BASE 1123→ out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length] 1124→ continue 1125→ } 1126→ out += c 1127→ } 1128→ return out 1129→} 1130→ 1131→// ── decode entry point ──────────────────────────────────────────────────── 1132→ 1133→export type HashDecodeResult = 1134→ | { ok: true; script: string; name: string | null } 1135→ | { ok: false; error: string } 1136→ 1137→/** 1138→ * Decode a pasted share string back into the original script. Accepts the 1139→ * full `Name:[…]` form or a bare `[…]` body, ▼ (current) and ▲ (v7.7) 1140→ * schemes; strips the invisible characters platforms sprinkle into copies; 1141→ * and verifies the check digits so a truncated or altered string is 1142→ * rejected instead of decoding into a corrupted script. 1143→ */ 1144→export function decodeScriptHash(input: string): HashDecodeResult { 1145→ if (input.length > 40_000) { 1146→ return { ok: false, error: 'That text is far too long to be a share string.' } 1147→ } 1148→ 1149→ // Trim whitespace AND stray noise from the edges (the encoder never emits 1150→ // either raw, so edge removal is always safe). 1151→ let text = input 1152→ while (text.length > 0 && isEdgeTrimable(text[0])) text = text.slice(1) 1153→ while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1) 1154→ if ( 1155→ text.length >= 2 && 1156→ ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) 1157→ ) { 1158→ text = text.slice(1, -1) 1159→ while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1) 1160→ } 1161→ // Any raw noise left is injected mangling (legit noise is symbol-coded), 1162→ // so it can be stripped everywhere without ever eating original content. 1163→ text = text.replace(PLATFORM_NOISE, '') 1164→ 1165→ // `Name:[▼…]`, `[▼…]`, `Name:[▲…]` or `[▲…]` 1166→ let payload: string | null = null 1167→ let name: string | null = null 1168→ const full = /^([\s\S]*?):\[([▼▲][\s\S]*)\]$/.exec(text) 1169→ if (full) { 1170→ name = full[1].trim() || null 1171→ payload = full[2] 1172→ } else if (/^\[([▼▲][\s\S]*)\]$/.test(text)) { 1173→ payload = text.slice(1, -1) 1174→ } 1175→ if (payload === null) { 1176→ // a string that still shows the "…:[▼/▲" signature was cut off 1177→ // somewhere after the opening — a character limit ate the end 1178→ const looksCutOff = /:\[[▼▲]/.test(text) || /^\[[▼▲]/.test(text) 1179→ return { 1180→ ok: false, 1181→ error: looksCutOff 1182→ ? 'This share string looks cut off -- it should end with "]". Copy the whole string and try again.' 1183→ : 'This does not look like an OVE share string -- it should read like "My Script:[▼…]".', 1184→ } 1185→ } 1186→ 1187→ // trailing 8 check digits 1188→ if (payload.length < 9 || !isCheckDigit(payload[payload.length - 8])) { 1189→ return { 1190→ ok: false, 1191→ error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.', 1192→ } 1193→ } 1194→ const digits = payload.slice(-8) 1195→ for (const d of digits) { 1196→ if (!isCheckDigit(d)) { 1197→ return { 1198→ ok: false, 1199→ error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.', 1200→ } 1201→ } 1202→ } 1203→ const marker = payload[0] 1204→ const body = payload.slice(1, -8) 1205→ 1206→ let script: string 1207→ if (marker === SCHEME_V2) { 1208→ // base form ⇔ the body contains a bare (unescaped) 〠 — the separator 1209→ // between payload digits and the bitmap header. The pair form escapes 1210→ // every 〠, and base-form payload digits can never be one. 1211→ let hasBareSep = false 1212→ for (let i = 0; i < body.length; i++) { 1213→ if (body[i] === ESCAPE) i++ 1214→ else if (body[i] === SEP) { 1215→ hasBareSep = true 1216→ break 1217→ } 1218→ } 1219→ if (hasBareSep) { 1220→ const dictBody = unpackBase(body) 1221→ if (dictBody === null) { 1222→ return { 1223→ ok: false, 1224→ error: 'This share string is damaged -- its packing header does not parse. Copy the whole string and try again.', 1225→ } 1226→ } 1227→ script = decodeDictBody(dictBody) 1228→ } else { 1229→ script = decodePairBody(body) 1230→ } 1231→ } else { 1232→ script = decodeLegacyBody(body) 1233→ } 1234→ 1235→ if (checkDigits(fnv1a(script)) !== digits) { 1236→ return { 1237→ ok: false, 1238→ error: 'The check digits do not match -- the string was altered or cut off while copying. Copy the whole string and try again.', 1239→ } 1240→ } 1241→ return { ok: true, script, name } 1242→} 1243→ 1244→// ── legend for the UI ───────────────────────────────────────────────────── 1245→ 1246→/** A few representative symbol meanings, shown in the share dialogs. */ 1247→export const SHARE_LEGEND: ReadonlyArray<{ symbol: string; meaning: string }> = [ 1248→ { symbol: '♤', meaning: 'line return' }, 1249→ { symbol: '♦', meaning: 'space' }, 1250→ { symbol: '♡', meaning: 'beats' }, 1251→ { symbol: '♠', meaning: 'beatage' }, 1252→ { symbol: '一', meaning: 'one packed digit -- 15 bits of script' }, 1253→] 1254→