1254 lines
No EOL
57 KiB
Text
1254 lines
No EOL
57 KiB
Text
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→ |