1→// AVA — .avs (Winamp AVS binary preset) → .ava auto-converter 2→// 3→// Parses the legacy .avs binary format (reverse-engineered against the 4→// vis_avs reference source), extracts the primary SuperScope / Texer II 5→// point program plus any AVSTrans #define macros and Jheriko Global 6→// register-setter code, translates it to the AVA script dialect, validates 7→// and smoke-runs every generated script through the real engine parser, and 8→// emits src/lib/ava/embedded-presets.ts. 9→// 10→// Run: bun tools/convert-avs.ts [inputRoot] (inputRoot defaults to /tmp/avsdrive/raw) 11→// 12→// Format reference (per grandchild/vis_avs source): 13→// magic "Nullsoft AVS Preset 0.2\x1a" (24 bytes) 14→// byte clearFlag 15→// effects: [int32 id] [if id>=0x4000: 32-byte apeId] [int32 len] [blob] 16→// id 36 SuperScope: [byte ver==1][point][frame][beat][init][int chsrc] 17→// [int ncolors][colors...][int drawmode] 18→// id -2 EffectList: [byte mode0|0x80][byte mode0][byte in][byte out] 19→// [byte extsize][ext ints while pos= 4) { 122→ const v = r.int32() 123→ if (r.pos > ext - 4) break 124→ void v 125→ } 126→ const children: Effect[] = [] 127→ while (r.remaining > 8) { 128→ const id = r.int32() 129→ let apeId: string | null = null 130→ if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0] 131→ const len = r.int32() 132→ if (len < 0 || len > r.remaining) break 133→ const childBlob = r.bytes(len) 134→ // special case: the v2.81+ codeable EffectList's own init/frame code 135→ // section, stored as a child entry with the Effect List APE id. 136→ if (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config') { 137→ const cr = new Reader(childBlob) 138→ if (cr.remaining >= 4) cr.int32() // use_code 139→ const gv = { init: cr.lpString(), frame: cr.lpString(), beat: '' } 140→ if (gv.init.trim() || gv.frame.trim()) { 141→ children.push({ id, apeId, kind: 'globalvars', enabled: true, gv }) 142→ } 143→ continue 144→ } 145→ children.push(parseEffect(id, apeId, childBlob)) 146→ } 147→ return { enabled, children } 148→} 149→ 150→function parseEffect(id: number, apeId: string | null, blob: Uint8Array): Effect { 151→ const base = { id, apeId, enabled: true } as Effect 152→ if (id === ID_SUPERSCOPE) { 153→ const r = new Reader(blob) 154→ const code = { init: '', frame: '', beat: '', point: '' } 155→ let spectrumSource = false 156→ let drawMode = 1 157→ const colors: number[] = [] 158→ const ver = r.byte() 159→ if (ver === 1) { 160→ code.point = r.lpString() 161→ code.frame = r.lpString() 162→ code.beat = r.lpString() 163→ code.init = r.lpString() 164→ } else { 165→ // ancient fixed 1024-byte block 166→ if (blob.length >= 1025) { 167→ const buf = latin1(blob.subarray(1, 1025)) 168→ code.point = buf.slice(0, 256).split('\0')[0] 169→ code.frame = buf.slice(256, 512).split('\0')[0] 170→ code.beat = buf.slice(512, 768).split('\0')[0] 171→ code.init = buf.slice(768, 1024).split('\0')[0] 172→ } 173→ } 174→ if (r.remaining >= 4) { 175→ const chsrc = r.int32() 176→ spectrumSource = (chsrc & 0b100) !== 0 177→ if (r.remaining >= 4) { 178→ const ncolors = r.int32() 179→ if (ncolors <= 16) { 180→ for (let i = 0; i < ncolors && r.remaining >= 4; i++) colors.push(r.int32() >>> 0) 181→ } 182→ if (r.remaining >= 4) drawMode = r.int32() 183→ } 184→ } 185→ return { ...base, kind: 'superscope', code, spectrumSource, drawMode, colors } 186→ } 187→ if (id >= 0x4000 && apeId === APE_TEXER2) { 188→ const r = new Reader(blob) 189→ r.int32() // version 190→ r.bytes(260) // image path 191→ r.int32() // resize 192→ r.int32() // wrap 193→ r.int32() // colorize 194→ r.int32() // unused 195→ const code = { init: r.lpString(), frame: r.lpString(), beat: r.lpString(), point: r.lpString() } 196→ return { ...base, kind: 'texer2', code, spectrumSource: false, drawMode: 0, colors: [] } 197→ } 198→ if (id === ID_EFFECTLIST || (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config')) { 199→ const { enabled, children } = parseEffectListBody(blob) 200→ return { ...base, kind: 'effectlist', enabled, children } 201→ } 202→ if (id >= 0x4000 && apeId === APE_EELTRANS) { 203→ const r = new Reader(blob) 204→ if (r.remaining >= 16) { 205→ r.int32(); r.int32(); r.int32(); r.int32() 206→ return { ...base, kind: 'eeltrans', transCode: r.ntRest() } 207→ } 208→ return { ...base, kind: 'other' } 209→ } 210→ if (id >= 0x4000 && apeId === APE_GLOBALVARS) { 211→ const r = new Reader(blob) 212→ if (r.remaining >= 28) { 213→ r.int32() // load time 214→ r.bytes(24) // unused 215→ const gv = { init: ntAt(r), frame: ntAt(r), beat: ntAt(r) } 216→ return { ...base, kind: 'globalvars', gv } 217→ } 218→ return { ...base, kind: 'other' } 219→ } 220→ if (id === ID_COMMENT) { 221→ const r = new Reader(blob) 222→ return { ...base, kind: 'comment', comment: r.lpString() } 223→ } 224→ return { ...base, kind: 'other' } 225→} 226→ 227→function ntAt(r: Reader): string { 228→ // null-terminated string starting at reader position (within the blob) 229→ const start = r.pos 230→ const d = r.data 231→ let end = start 232→ while (end < d.length && d[end] !== 0) end++ 233→ r.pos = Math.min(end + 1, d.length) 234→ return latin1(d.subarray(start, end)) 235→} 236→ 237→function parseAvsFile(path: string): Effect[] { 238→ const data = new Uint8Array(readFileSync(path)) 239→ const magic = 'Nullsoft AVS Preset 0.2\x1a' 240→ const head = latin1(data.subarray(0, magic.length)) 241→ if (head !== magic) throw new Error(`bad magic: ${JSON.stringify(head.slice(0, 30))}`) 242→ const r = new Reader(data.subarray(magic.length)) 243→ r.byte() // clear flag 244→ const effects: Effect[] = [] 245→ while (r.remaining > 8) { 246→ const id = r.int32() 247→ let apeId: string | null = null 248→ if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0] 249→ const len = r.int32() 250→ if (len < 0 || len > r.remaining) break 251→ effects.push(parseEffect(id, apeId, r.bytes(len))) 252→ } 253→ return effects 254→} 255→ 256→// --------------------------------------------------------------------------- 257→// AVSTrans #define macro preprocessor 258→// --------------------------------------------------------------------------- 259→ 260→interface Macro { 261→ name: string 262→ params: string[] | null // null = object-like 263→ body: string 264→} 265→ 266→/** AVSTrans built-in constants (available even without an AVSTrans module). */ 267→const AVSTRANS_BUILTINS: Macro[] = [ 268→ { name: '$pi', params: null, body: '3.14159265358979' }, 269→ { name: '$twopi', params: null, body: '6.28318530717959' }, 270→ { name: '$e', params: null, body: '2.71828182845905' }, 271→ { name: '$phi', params: null, body: '1.61803398874989' }, 272→] 273→ 274→function extractDefines(sources: string[]): { macros: Macro[]; failNote: string | null } { 275→ const macros: Macro[] = [] 276→ let failNote: string | null = null 277→ const defineRe = /^[ \t]*#define\s+(.+)$/gm 278→ for (const src of sources) { 279→ if (!src.includes('#define')) continue 280→ // join backslash line-continuations first 281→ const joined = src.replace(/\\\r?\n/g, ' ') 282→ const matches = joined.match(defineRe) ?? [] 283→ for (const m of matches) { 284→ const rest = m.replace(/^[ \t]*#define\s+/, '').trim() 285→ const fn = rest.match(/^([A-Za-z0-9_$]+)\s*\(([^)]*)\)\s*([\s\S]*)$/) 286→ if (fn) { 287→ const params = fn[2].split(',').map((p) => p.trim()).filter(Boolean) 288→ macros.push({ name: fn[1], params, body: fn[3] }) 289→ } else { 290→ const obj = rest.match(/^([A-Za-z0-9_$]+)\s+([\s\S]*)$/) 291→ if (obj) macros.push({ name: obj[1], params: null, body: obj[2] }) 292→ else failNote = 'unparsed #define' 293→ } 294→ } 295→ } 296→ // user defines override the AVSTrans built-ins 297→ const byName = new Map(macros.map((m) => [m.name, m])) 298→ for (const b of AVSTRANS_BUILTINS) if (!byName.has(b.name)) macros.push(b) 299→ return { macros, failNote } 300→} 301→ 302→/** Split a macro CALL starting at `openPos` (must be '('). */ 303→function readCallArgs(src: string, openPos: number): { args: string[]; end: number } | null { 304→ if (src[openPos] !== '(') return null 305→ let depth = 0 306→ let start = openPos + 1 307→ const args: string[] = [] 308→ for (let i = openPos; i < src.length; i++) { 309→ const c = src[i] 310→ if (c === '(') depth++ 311→ else if (c === ')') { 312→ depth-- 313→ if (depth === 0) { 314→ args.push(src.slice(start, i)) 315→ return { args, end: i + 1 } 316→ } 317→ } else if (c === ',' && depth === 1) { 318→ args.push(src.slice(start, i)) 319→ start = i + 1 320→ } 321→ } 322→ return null 323→} 324→ 325→function applyMacros(code: string, macros: Macro[], depth = 0): string { 326→ if (depth > 24 || !macros.length) return code 327→ let out = code 328→ let changed = false 329→ for (const macro of macros) { 330→ const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.name)}($|[^A-Za-z0-9_(]|\\()`, 'g') 331→ let result = '' 332→ let last = 0 333→ let m: RegExpExecArray | null 334→ let macroChanged = false 335→ while ((m = re.exec(out))) { 336→ const at = m.index + m[1].length 337→ // skip #define lines themselves 338→ const lineStart = out.lastIndexOf('\n', at) + 1 339→ const lineHead = out.slice(lineStart, at).trimStart() 340→ if (lineHead.startsWith('#')) continue 341→ if (macro.params) { 342→ const call = readCallArgs(out, at + macro.name.length) 343→ if (!call || call.args.length !== macro.params.length) continue 344→ let body = macro.body 345→ // token-concat ##param## first, then plain params 346→ for (let i = 0; i < macro.params.length; i++) { 347→ body = body.split(`##${macro.params[i]}##`).join(call.args[i].trim()) 348→ } 349→ for (let i = 0; i < macro.params.length; i++) { 350→ body = body.replace(new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.params[i])}($|[^A-Za-z0-9_])`, 'g'), 351→ (_s, pre, post) => `${pre}(${call.args[i].trim()})${post}`) 352→ } 353→ result += out.slice(last, at) + `(${body})` 354→ last = call.end 355→ } else { 356→ result += out.slice(last, at) + `(${macro.body})` 357→ last = at + macro.name.length 358→ } 359→ macroChanged = true 360→ re.lastIndex = last 361→ } 362→ if (macroChanged) { 363→ result += out.slice(last) 364→ out = result 365→ changed = true 366→ } 367→ } 368→ if (changed) return applyMacros(out, macros, depth + 1) 369→ return out 370→} 371→ 372→function escapeRe(s: string): string { 373→ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 374→} 375→ 376→function stripDirectives(code: string): string { 377→ return code 378→ .split('\n') 379→ .filter((l) => !/^\s*#/.test(l)) 380→ .join('\n') 381→} 382→ 383→// --------------------------------------------------------------------------- 384→// code translation 385→// --------------------------------------------------------------------------- 386→ 387→function translateCode(code: string, spectrumSource: boolean): string { 388→ let out = code 389→ // linesize -> size (AVS per-point line width) 390→ out = out.replace(/\blinesize\b/g, 'size') 391→ // AVS rand(n) returns an integer 0..n-1 — our deterministic rand(seed) differs 392→ out = out.replace(/\brand\b/g, 'rnd') 393→ // spectrum-source scopes: v should read the spectrum (AVS: byte/128-1) 394→ if (spectrumSource) out = out.replace(/\bv\b/g, '(sv*2-1)') 395→ return out 396→} 397→ 398→function assignsVar(code: string, name: string): boolean { 399→ return new RegExp(`(^|[^A-Za-z0-9_])${name}\\s*=[^=]`).test(code) 400→} 401→ 402→// --------------------------------------------------------------------------- 403→// candidate collection 404→// --------------------------------------------------------------------------- 405→ 406→interface Candidates { 407→ scopes: Array 408→ eeltrans: string[] 409→ gv: Array<{ init: string; frame: string; beat: string }> 410→ comment: string | null 411→ otherKinds: Set 412→} 413→ 414→function collect(effects: Effect[], into: Candidates): void { 415→ for (const e of effects) { 416→ if (!e.enabled) continue 417→ switch (e.kind) { 418→ case 'superscope': 419→ case 'texer2': 420→ into.scopes.push(e as Effect & { kind: 'superscope' | 'texer2' }) 421→ break 422→ case 'eeltrans': 423→ if (e.transCode) into.eeltrans.push(e.transCode) 424→ break 425→ case 'globalvars': 426→ if (e.gv) into.gv.push(e.gv) 427→ break 428→ case 'comment': 429→ if (!into.comment && e.comment) into.comment = e.comment 430→ break 431→ case 'effectlist': 432→ if (e.children) collect(e.children, into) 433→ break 434→ default: 435→ into.otherKinds.add(describeEffect(e)) 436→ } 437→ } 438→} 439→ 440→const BUILTIN_NAMES: Record = { 441→ 0: 'Simple', 1: 'DotPlane', 2: 'OscilloscopeStar', 3: 'Fadeout', 4: 'BlitterFeedback', 442→ 5: 'OnBeatClear', 6: 'Blur', 7: 'BassSpin', 8: 'MovingParticle', 9: 'RotoBlitter', 10: 'SVP', 443→ 11: 'Colorfade', 12: 'ColorClip', 13: 'RotStar', 14: 'Ring', 15: 'Movement', 16: 'Scatter', 444→ 17: 'DotGrid', 18: 'BufferSave', 19: 'DotFountain', 20: 'Water', 22: 'Brightness', 23: 'Interleave', 445→ 24: 'Grain', 25: 'ClearScreen', 26: 'Mirror', 27: 'Starfield', 29: 'Bump', 30: 'Mosaic', 446→ 31: 'WaterBump', 32: 'Video', 33: 'CustomBPM', 34: 'Picture', 35: 'DynamicDistanceModifier', 447→ 37: 'Invert', 38: 'UniqueTone', 39: 'Timescope', 40: 'SetRenderMode', 41: 'Interferences', 448→ 42: 'DynamicShift', 43: 'DynamicMovement', 44: 'FastBright', 45: 'ColorModifier', 449→} 450→ 451→function describeEffect(e: Effect): string { 452→ if (e.apeId) return e.apeId 453→ return BUILTIN_NAMES[e.id] ?? `effect#${e.id}` 454→} 455→ 456→// --------------------------------------------------------------------------- 457→// fallback templates (for presets whose visuals are movement/APE driven) 458→// --------------------------------------------------------------------------- 459→ 460→function fallbackTemplate(seed: number, name: string): { content: string; style: string } { 461→ const styles = [ 462→ { 463→ style: 'pulse rings', 464→ mode: 'lines', 465→ content: `name: ${JSON.stringify(name)} 466→mode: lines 467→blend: additive 468→persist: 0.88 469→linewidth: 2 470→ 471→[init] 472→n = 720 473→ 474→[frame] 475→hue = hue + dt * (0.06 + mid * 0.2) 476→travel = travel + dt * (0.3 + bass_att * 1.4) 477→ 478→[beat] 479→flash = 1 480→ 481→[point] 482→g = floor(i * 9) 483→gi = frac(i * 9) 484→ph = frac(rand(g * 5.1 + 1) + travel * (0.6 + rand(g + 17) * 0.8)) 485→a = gi * 6.28318 486→rad = ph * (0.25 + bass * 0.6 + flash * 0.08) 487→x = sin(a) * rad / aspect 488→y = cos(a) * rad 489→fade = sin(ph * 3.14159) 490→red = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7)) 491→green = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 2.09)) 492→blue = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 4.19)) 493→skip = if(above(ph, 0.95), 1, 0) 494→`, 495→ }, 496→ { 497→ style: 'tunnel', 498→ mode: 'lines', 499→ content: `name: ${JSON.stringify(name)} 500→mode: lines 501→blend: additive 502→persist: 0.9 503→linewidth: 1.6 504→ 505→[init] 506→n = 900 507→ 508→[frame] 509→t = t + dt * (0.25 + bass_att * 1.5) 510→hue = hue + dt * 0.1 511→ 512→[point] 513→ring = floor(i * 24) 514→ri = frac(i * 24) 515→z = frac(ring / 24 + t) 516→rad = 0.06 + z * 0.9 517→a = ri * 6.28318 + ring * 0.35 + sin(t * 0.7) * 2 518→x = sin(a) * rad * (0.55 + sv * 0.9) / aspect 519→y = cos(a) * rad * (0.55 + sv * 0.9) 520→f = (1 - z) * (0.35 + bass * 0.65) 521→red = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4)) 522→green = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 2.09)) 523→blue = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 4.19)) 524→`, 525→ }, 526→ { 527→ style: 'starfield', 528→ mode: 'dots', 529→ content: `name: ${JSON.stringify(name)} 530→mode: dots 531→blend: additive 532→persist: 0.82 533→linewidth: 2.5 534→ 535→[init] 536→n = 850 537→ 538→[frame] 539→travel = travel + dt * (0.2 + vol_att * 1.6) 540→hue = hue + dt * 0.05 541→ 542→[point] 543→a = rand(i * 7.3) * 6.28318 544→z = frac(rand(i * 3.1 + 5) + travel * (0.5 + rand(i + 9) * 0.7)) 545→rad = z * z * 1.1 546→x = sin(a) * rad / aspect 547→y = cos(a) * rad 548→tw = 0.4 + 0.6 * sin(z * 40 + time * 3) * (0.5 + treb) 549→red = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3)) 550→green = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 2.09)) 551→blue = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 4.19)) 552→size = 1 + z * 3 * (0.5 + bass) 553→skip = if(above(z, 0.97), 1, 0) 554→`, 555→ }, 556→ { 557→ style: 'lattice', 558→ mode: 'dots', 559→ content: `name: ${JSON.stringify(name)} 560→mode: dots 561→blend: additive 562→persist: 0.78 563→linewidth: 2.5 564→ 565→[init] 566→cols = 36 567→n = cols * cols 568→ 569→[frame] 570→rot = rot + dt * (0.05 + mid * 0.4) 571→hue = hue + dt * (0.04 + treb * 0.1) 572→ 573→[point] 574→cx = floor(i * cols) / (cols - 1) 575→cy = frac(i * cols) 576→h = sv * 0.35 577→ux = (cx - 0.5) * 1.6 578→uy = (cy - 0.5) * 1.6 579→rx = ux * cos(rot) - uy * sin(rot) 580→ry = ux * sin(rot) + uy * cos(rot) 581→x = (rx / aspect) * (1 - h * 0.7) 582→y = ry * (1 - h * 0.7) 583→lum = 0.25 + h * 2.2 584→red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3)) 585→green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 2.09)) 586→blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 4.19)) 587→size = 1.2 + h * 5 588→`, 589→ }, 590→ { 591→ style: 'swirl', 592→ mode: 'lines', 593→ content: `name: ${JSON.stringify(name)} 594→mode: lines 595→blend: additive 596→persist: 0.9 597→linewidth: 1.6 598→ 599→[init] 600→n = 1100 601→ 602→[frame] 603→t = t + dt * (0.3 + bass_att * 1.8) 604→hue = hue + dt * 0.08 + beat * 0.01 605→ 606→[point] 607→arm = floor(i * 5) 608→fa = frac(i * 5) 609→d = fa 610→ang = arm * 1.2566 + d * (1.4 + mid * 3) * 3.14159 + t 611→rad = (0.05 + d * d * 1.15) * (0.7 + bass * 0.5) 612→x = sin(ang) * rad / aspect 613→y = cos(ang) * rad 614→red = (0.3 + 0.7 * d) * (0.3 + bass) 615→green = (0.3 + 0.7 * d) * (0.25 + mid) 616→blue = (0.3 + 0.7 * d) * (0.2 + treb + hue * 0.2) 617→skip = if(below(fa, 0.04), 1, 0) 618→`, 619→ }, 620→ { 621→ style: 'kaleido', 622→ mode: 'lines', 623→ content: `name: ${JSON.stringify(name)} 624→mode: lines 625→blend: additive 626→persist: 0.86 627→linewidth: 1.8 628→ 629→[init] 630→n = 560 631→segs = 8 632→ 633→[frame] 634→rot = rot + dt * (0.08 + mid_att * 0.5) 635→hue = hue + dt * (0.07 + treb * 0.08) 636→ 637→[beat] 638→flash = 1 639→ 640→[point] 641→seg = floor(i * segs) 642→si = frac(i * segs) 643→a0 = si * 6.28318 / segs + rot 644→amp = 0.12 + sv * 0.55 + bass * 0.25 + flash * 0.05 645→rad = 0.15 + amp * (0.4 + 0.6 * sin(a0 * 6 + time * 2)) 646→a = seg * 6.28318 / segs + a0 * if(below(seg % 2, 1), 1, -1) 647→x = sin(a) * rad / aspect 648→y = cos(a) * rad 649→lum = 0.35 + amp * 1.2 650→red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg)) 651→green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 2.09)) 652→blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 4.19)) 653→skip = if(above(si, 0.96), 1, 0) 654→`, 655→ }, 656→ ] 657→ return styles[seed % styles.length] 658→} 659→ 660→// --------------------------------------------------------------------------- 661→// smoke test — runs the generated script headlessly through the real engine env 662→// --------------------------------------------------------------------------- 663→ 664→function smokeTest(content: string): { ok: boolean; error?: string; n: number; ms: number } { 665→ const t0 = Date.now() 666→ const v = validateScript(content) 667→ if (!v.ok || !v.script) return { ok: false, error: v.error ? `${v.error.section} line ${v.error.line}: ${v.error.message}` : 'parse failed', n: 0, ms: 0 } 668→ resetEelBuffers() 669→ AVA_HOOKS.spectrumAt = (p) => 0.25 + 0.25 * Math.sin(p * 18 + 1) 670→ AVA_HOOKS.waveAt = (p) => Math.sin(p * 12.56) * 0.8 671→ const env: Record = { n: 400, t: 0, time: 0, frame: 0 } 672→ const script = v.script 673→ try { 674→ script.initProg?.exec(env) 675→ } catch (err) { 676→ return { ok: false, error: `init: ${String(err)}`, n: 0, ms: Date.now() - t0 } 677→ } 678→ const n = Math.min(512, Math.max(4, Math.round(env.n ?? 400))) 679→ let nan = 0 680→ try { 681→ for (let f = 0; f < 36; f++) { 682→ env.dt = 1 / 60 683→ env.time = (f + 1) / 60 684→ env.frame = f + 1 685→ AVA_HOOKS.now = () => env.time ?? 0 686→ env.beat = f % 15 === 0 ? 1 : 0 687→ env.bass = 0.3 + 0.3 * Math.sin(f * 0.4) 688→ env.mid = 0.3 689→ env.treb = 0.25 690→ env.bass_att = env.bass 691→ env.mid_att = 0.3 692→ env.treb_att = 0.25 693→ env.vol = 0.4 694→ env.vol_att = 0.4 695→ env.w = 1280 696→ env.h = 720 697→ env.aspect = 1280 / 720 698→ script.frameProg?.exec(env) 699→ if (env.beat) script.beatProg?.exec(env) 700→ for (let k = 0; k < n; k++) { 701→ env.i = k / (n - 1 || 1) 702→ env.v = AVA_HOOKS.waveAt(env.i) 703→ env.sv = AVA_HOOKS.spectrumAt(env.i) 704→ env.x = 0; env.y = 0; env.red = 1; env.green = 1; env.blue = 1; env.skip = 0; env.size = 2; env.alpha = 1 705→ script.pointProg?.exec(env) 706→ if (!Number.isFinite(env.x) || !Number.isFinite(env.y)) nan++ 707→ } 708→ } 709→ } catch (err) { 710→ return { ok: false, error: `runtime: ${String(err)}`, n, ms: Date.now() - t0 } 711→ } 712→ const ms = Date.now() - t0 713→ // some presets deliberately NaN points (skip via NaN) — allow up to 40% 714→ if (nan > n * 36 * 0.4) return { ok: false, error: `${nan} NaN points of ${n * 36}`, n, ms } 715→ return { ok: true, n, ms } 716→} 717→ 718→// --------------------------------------------------------------------------- 719→// name / author extraction 720→// --------------------------------------------------------------------------- 721→ 722→function extractNameAuthor(filename: string): { name: string; author: string } { 723→ let base = filename.replace(/\.avs$/i, '') 724→ base = base.replace(/^\d{1,2}\s+/, '') // "01 dynamic duo - ..." numbering 725→ // "Author - Name" (spaces around dash) 726→ const spaced = base.match(/^(.+?)\s+-\s+(.+)$/) 727→ if (spaced) return { author: spaced[1].trim(), name: spaced[2].trim() } 728→ // "Author-Name" (no spaces — e.g. "zxe-Urantia", "Degnic-Adrealine rush") 729→ const tight = base.match(/^([A-Za-z0-9_&']+)-([A-Za-z].+)$/) 730→ if (tight) return { author: tight[1].trim(), name: tight[2].trim() } 731→ return { author: '', name: base } 732→} 733→ 734→function hashStr(s: string): number { 735→ let h = 2166136261 736→ for (let i = 0; i < s.length; i++) { 737→ h ^= s.charCodeAt(i) 738→ h = Math.imul(h, 16777619) 739→ } 740→ return Math.abs(h) 741→} 742→ 743→// --------------------------------------------------------------------------- 744→// the converter 745→// --------------------------------------------------------------------------- 746→ 747→export { parseAvsFile, collect, applyMacros, extractDefines, translateCode, smokeTest, extractNameAuthor, fallbackTemplate, convertOne } 748→export type { Effect, Candidates, OutputPreset, Macro } 749→ 750→interface OutputPreset { 751→ folder: string 752→ name: string 753→ author: string 754→ description: string 755→ originalFile: string 756→ content: string 757→} 758→ 759→function convertOne(file: string, relDir: string, absPath: string): { preset: OutputPreset; status: 'ported' | 'fallback'; note: string } { 760→ const { name, author } = extractNameAuthor(file) 761→ const effects = parseAvsFile(absPath) 762→ const cands: Candidates = { scopes: [], eeltrans: [], gv: [], comment: null, otherKinds: new Set() } 763→ collect(effects, cands) 764→ 765→ const folder = 'root/embedded/' + relDir 766→ const originalFile = `/avs-originals/embedded/${relDir}/${file}` 767→ const desc = (extra: string): string => { 768→ const by = author ? ` by ${author}` : '' 769→ const src = `Auto-ported from the Winamp AVS preset "${file}"${by}.` 770→ return extra ? `${src} ${extra}` : src 771→ } 772→ 773→ const makeFallback = (reason: string) => { 774→ const tpl = fallbackTemplate(hashStr(name), name) 775→ return { 776→ preset: { 777→ folder, 778→ name, 779→ author, 780→ description: desc(`The original's visuals are driven by ${reason}, which the AVA script engine does not support — this is a generated stand-in that keeps the beat & band reactivity.`), 781→ originalFile, 782→ content: tpl.content, 783→ }, 784→ status: 'fallback' as const, 785→ note: `${reason} → ${tpl.style} stand-in`, 786→ } 787→ } 788→ 789→ // pick the richest point program 790→ const scored = cands.scopes 791→ .map((s) => ({ s, score: (s.code?.point.length ?? 0) + (s.code?.frame.length ?? 0) * 0.3 })) 792→ .sort((a, b) => b.score - a.score) 793→ if (!scored.length || (scored[0].s.code?.point.length ?? 0) < 12) { 794→ const reason = cands.otherKinds.size 795→ ? Array.from(cands.otherKinds).slice(0, 3).join(', ') 796→ : 'no codeable scope' 797→ return makeFallback(reason) 798→ } 799→ const scope = scored[0].s 800→ const code = scope.code! 801→ 802→ // reject trivial scopes (e.g. a 2-point checker) — they are not the real 803→ // visual. If init pins a tiny literal n and the frame code never sets n, 804→ // the preset's meat is elsewhere (movements / APEs). 805→ const initNLit = code.init.match(/(^|[^A-Za-z0-9_])n\s*=\s*([0-9.]+)\s*;/) 806→ const frameSetsN = assignsVar(code.frame, 'n') 807→ if (initNLit && !frameSetsN && parseFloat(initNLit[2]) < 8) { 808→ return makeFallback('a trivial scope (the real work is movement/APE driven)') 809→ } 810→ 811→ // macro preprocessing (AVSTrans defines + local defines) 812→ const macroSources = [...cands.eeltrans, code.init, code.frame, code.beat, code.point] 813→ const { macros } = extractDefines(macroSources) 814→ let init = code.init 815→ let frame = code.frame 816→ let beat = code.beat 817→ let point = code.point 818→ let gvNote = '' 819→ if (macros.length) { 820→ init = applyMacros(init, macros) 821→ frame = applyMacros(frame, macros) 822→ beat = applyMacros(beat, macros) 823→ point = applyMacros(point, macros) 824→ } 825→ // merge Jheriko Global register-setters as a preamble (sets reg00=dt etc.) 826→ if (cands.gv.length) { 827→ const gv = cands.gv[0] 828→ const gvi = stripDirectives(applyMacros(gv.init, macros)) 829→ const gvf = stripDirectives(applyMacros(gv.frame, macros)) 830→ const gvb = stripDirectives(applyMacros(gv.beat, macros)) 831→ if (gvi.trim()) init = `${gvi}\n${init}` 832→ if (gvf.trim()) frame = `${gvf}\n${frame}` 833→ if (gvb.trim()) beat = `${gvb}\n${beat}` 834→ gvNote = ' + global register code' 835→ } 836→ init = stripDirectives(init) 837→ frame = stripDirectives(frame) 838→ beat = stripDirectives(beat) 839→ point = stripDirectives(point) 840→ 841→ // token rewrites 842→ const spectrum = scope.spectrumSource === true 843→ init = translateCode(init, spectrum) 844→ frame = translateCode(frame, spectrum) 845→ beat = translateCode(beat, spectrum) 846→ point = translateCode(point, spectrum) 847→ 848→ // AVS `t` is auto-advancing time — if the code never assigns t, map reads to `time` 849→ const allCode = `${init}\n${frame}\n${beat}\n${point}` 850→ if (!assignsVar(allCode, 't')) { 851→ init = init.replace(/\bt\b/g, 'time') 852→ frame = frame.replace(/\bt\b/g, 'time') 853→ beat = beat.replace(/\bt\b/g, 'time') 854→ point = point.replace(/\bt\b/g, 'time') 855→ } 856→ 857→ // default n if never set 858→ if (!assignsVar(allCode, 'n')) init = `n = 400\n${init}` 859→ 860→ // color cycling fallback when the point code never sets colors 861→ const setsColors = assignsVar(point, 'red') || assignsVar(point, 'green') || assignsVar(point, 'blue') 862→ let colorFrame = '' 863→ let colorPoint = '' 864→ if (!setsColors && scope.colors && scope.colors.length) { 865→ const cols = scope.colors.map((c) => ({ 866→ r: ((c >> 16) & 255) / 255, 867→ g: ((c >> 8) & 255) / 255, 868→ b: (c & 255) / 255, 869→ })) 870→ const gen = cols 871→ .map((c, i) => `if(avsi==${i}, avsr=${c.r.toFixed(3)}; avsg=${c.g.toFixed(3)}; avsb=${c.b.toFixed(3)};)`) 872→ .join('\n') 873→ colorFrame = `avsc = avsc + 1;\navsp = avsc % ${cols.length * 64};\navsi = floor(avsp / 64);\navsf = (avsp % 64) / 64;\n${gen}` 874→ const c1 = cols[0] 875→ colorFrame += `\nif(avsi == ${cols.length - 1}, avsr2=${c1.r.toFixed(3)}; avsg2=${c1.g.toFixed(3)}; avsb2=${c1.b.toFixed(3)};)` 876→ for (let i = 0; i < cols.length - 1; i++) { 877→ colorFrame += `\nif(avsi == ${i}, avsr2=${cols[i + 1].r.toFixed(3)}; avsg2=${cols[i + 1].g.toFixed(3)}; avsb2=${cols[i + 1].b.toFixed(3)};)` 878→ } 879→ colorFrame += `\navsr = avsr * (1 - avsf) + avsr2 * avsf;\navsg = avsg * (1 - avsf) + avsg2 * avsf;\navsb = avsb * (1 - avsf) + avsb2 * avsf;` 880→ colorPoint = 'red = avsr;\ngreen = avsg;\nblue = avsb;' 881→ } else if (!setsColors) { 882→ colorFrame = 'avsh = avsh + dt * 0.15;' 883→ colorPoint = 'red = 0.55 + 0.45 * sin(avsh * 6.28318);\ngreen = 0.55 + 0.45 * sin(avsh * 6.28318 + 2.09);\nblue = 0.55 + 0.45 * sin(avsh * 6.28318 + 4.19);' 884→ } 885→ 886→ const mode = scope.kind === 'texer2' ? 'dots' : scope.drawMode === 0 ? 'dots' : 'lines' 887→ const linewidth = mode === 'dots' ? 3 : 2 888→ 889→ const content = `# Auto-ported from "${file}" (Winamp AVS${author ? ` / ${author}` : ''}) 890→# ${scope.kind === 'texer2' ? 'Texer II' : 'SuperScope'} point program${gvNote}${spectrum ? ', spectrum source' : ''}${macros.length ? `, ${macros.length} AVSTrans macros expanded` : ''}. 891→name: ${JSON.stringify(name)} 892→author: ${JSON.stringify(author)} 893→mode: ${mode} 894→blend: additive 895→persist: ${mode === 'dots' ? 0.78 : 0.88} 896→linewidth: ${linewidth} 897→ysign: -1 898→ 899→[init] 900→${init.trim() || 'n = 400'} 901→ 902→[frame] 903→${[frame.trim(), colorFrame].filter(Boolean).join('\n')} 904→ 905→[beat] 906→${beat.trim() || 'flash = 1'} 907→ 908→[point] 909→${[point.trim(), colorPoint].filter(Boolean).join('\n')} 910→` 911→ 912→ const smoke = smokeTest(content) 913→ if (!smoke.ok) { 914→ return makeFallback(`unportable scope code (${smoke.error})`) 915→ } 916→ const perf = smoke.ms > 900 ? ' (heavy script — point count clamped)' : '' 917→ if (smoke.ms > 900) { 918→ // clamp n for heavy scripts so the browser keeps 60fps 919→ const patched = content.replace(/\[init\]\n/, `[init]\nn = if(above(n, 2048), 2048, n)\n`) 920→ const recheck = smokeTest(patched) 921→ if (recheck.ok) { 922→ return { 923→ preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.${perf}`), originalFile, content: patched }, 924→ status: 'ported', 925→ note: `ok (n clamped, ${smoke.ms}ms → ${recheck.ms}ms)`, 926→ } 927→ } 928→ } 929→ return { 930→ preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.`), originalFile, content }, 931→ status: 'ported', 932→ note: `ok (${smoke.ms}ms, n=${smoke.n})`, 933→ } 934→} 935→ 936→// --------------------------------------------------------------------------- 937→// main 938→// --------------------------------------------------------------------------- 939→ 940→const inputRoot = process.argv[2] ?? '/tmp/avsdrive/raw' 941→const outDir = 'tools/converted' 942→const originalsDir = 'public/avs-originals/embedded' 943→ 944→const SKIP_FOLDERS = new Set(['Synth-C']) // hand-adapted versions ship in BUILTIN_PRESETS 945→ 946→function walk(dir: string): string[] { 947→ const out: string[] = [] 948→ for (const entry of readdirSync(dir)) { 949→ const p = join(dir, entry) 950→ if (statSync(p).isDirectory()) { 951→ if (SKIP_FOLDERS.has(entry)) continue 952→ out.push(...walk(p)) 953→ } else if (entry.toLowerCase().endsWith('.avs')) out.push(p) 954→ } 955→ return out 956→} 957→ 958→const isMain = typeof process !== 'undefined' && process.argv[1] && process.argv[1].includes('convert-avs') 959→if (!isMain) { 960→ // imported as a library — skip main 961→} else { 962→main() 963→} 964→ 965→function main() { 966→ 967→const files = walk(inputRoot).sort() 968→console.log(`converting ${files.length} .avs files from ${inputRoot}`) 969→const results: OutputPreset[] = [] 970→const report: Array<{ file: string; status: string; note: string }> = [] 971→let ported = 0 972→let fallback = 0 973→ 974→for (const abs of files) { 975→ const rel = relative(inputRoot, abs) 976→ const relDir = dirname(rel) === '.' ? '' : dirname(rel) 977→ const file = basename(rel) 978→ try { 979→ const r = convertOne(file, relDir, abs) 980→ results.push(r.preset) 981→ report.push({ file: rel, status: r.status, note: r.note }) 982→ if (r.status === 'ported') ported++ 983→ else fallback++ 984→ console.log(`${r.status === 'ported' ? '✔' : '○'} ${rel} — ${r.note}`) 985→ // keep a copy of the generated .ava for reference 986→ const destAva = join(outDir, relDir, file.replace(/\.avs$/i, '.ava')) 987→ mkdirSync(dirname(destAva), { recursive: true }) 988→ writeFileSync(destAva, r.preset.content) 989→ // copy the original binary 990→ const destOrig = join(originalsDir, relDir, file) 991→ mkdirSync(dirname(destOrig), { recursive: true }) 992→ copyFileSync(abs, destOrig) 993→ } catch (err) { 994→ report.push({ file: rel, status: 'error', note: String(err) }) 995→ fallback++ 996→ console.log(`✘ ${rel} — ${String(err)}`) 997→ } 998→} 999→ 1000→// emit the TS module 1001→const seen = new Set() 1002→for (const p of results) { 1003→ const key = `${p.folder}//${p.name}` 1004→ if (seen.has(key)) console.log(`!! duplicate name in folder: ${key}`) 1005→ seen.add(key) 1006→} 1007→ 1008→const tsHeader = `// AVA — embedded preset library (AUTO-GENERATED by tools/convert-avs.ts — do not edit) 1009→// 1010→// ${results.length} Winamp AVS presets from the user's Google Drive collection, 1011→// auto-ported to the AVA script dialect by the converter. Ported presets run 1012→// the original SuperScope / Texer II code (machine-translated); stand-in 1013→// presets are generated visuals for originals that rely on effects the script 1014→// engine does not support (movements, APEs). 1015→ 1016→export interface EmbeddedPreset { 1017→ folder: string 1018→ name: string 1019→ author: string 1020→ description: string 1021→ originalFile?: string 1022→ content: string 1023→} 1024→ 1025→` 1026→ 1027→const lines: string[] = [] 1028→for (const p of results) { 1029→ lines.push(` {\n folder: ${JSON.stringify(p.folder)},\n name: ${JSON.stringify(p.name)},\n author: ${JSON.stringify(p.author)},\n description: ${JSON.stringify(p.description)},\n originalFile: ${JSON.stringify(p.originalFile)},\n content: ${JSON.stringify(p.content)},\n },`) 1030→} 1031→const ts = tsHeader + `export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [\n${lines.join('\n')}\n]\n` 1032→writeFileSync('src/lib/ava/embedded-presets.ts', ts) 1033→ 1034→console.log(`\nDone: ${ported} ported, ${fallback} fallback, ${results.length} total`) 1035→console.log(`→ src/lib/ava/embedded-presets.ts (${(ts.length / 1024).toFixed(0)} KB)`) 1036→console.log(`→ ${originalsDir}/ (${results.length} .avs originals)`) 1037→writeFileSync('tools/convert-report.json', JSON.stringify(report, null, 1)) 1038→} 1039→