openvisengine/tool-results/read_1793817131261_fb699e6a294f.txt
2026-11-04 18:48:09 +00:00

1254 lines
No EOL
57 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

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

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<string, string>([
404→ ['\uFE0E', '⌸'],
405→ ['\uFE0F', '⌹'],
406→ ['\u200B', '⌺'],
407→ ['\u200C', '⌻'],
408→ ['\u200D', '⌼'],
409→ ['\u2060', '⌽'],
410→ ['\uFEFF', '⌾'],
411→])
412→const NOISE_CHAR_FROM_SYMBOL = new Map<string, string>(
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<string, DictEntry[]>()
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<string, string>()
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<string>(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<string, number>(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<string>([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<string, number>()
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→ * <payload digits> 〠 <dict bitmap> <ascii bitmap> <raw non-ASCII tail>
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<string>()
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<string, number>()
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<string>(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<string, string>()
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→