// AVA — .avs (Winamp AVS binary preset) → .ava auto-converter // // Parses the legacy .avs binary format (reverse-engineered against the // vis_avs reference source), extracts the primary SuperScope / Texer II // point program plus any AVSTrans #define macros and Jheriko Global // register-setter code, translates it to the AVA script dialect, validates // and smoke-runs every generated script through the real engine parser, and // emits src/lib/ava/embedded-presets.ts. // // Run: bun tools/convert-avs.ts [inputRoot] (inputRoot defaults to /tmp/avsdrive/raw) // // Format reference (per grandchild/vis_avs source): // magic "Nullsoft AVS Preset 0.2\x1a" (24 bytes) // byte clearFlag // effects: [int32 id] [if id>=0x4000: 32-byte apeId] [int32 len] [blob] // id 36 SuperScope: [byte ver==1][point][frame][beat][init][int chsrc] // [int ncolors][colors...][int drawmode] // id -2 EffectList: [byte mode0|0x80][byte mode0][byte in][byte out] // [byte extsize][ext ints while pos= 4) { const v = r.int32() if (r.pos > ext - 4) break void v } const children: Effect[] = [] while (r.remaining > 8) { const id = r.int32() let apeId: string | null = null if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0] const len = r.int32() if (len < 0 || len > r.remaining) break const childBlob = r.bytes(len) // special case: the v2.81+ codeable EffectList's own init/frame code // section, stored as a child entry with the Effect List APE id. if (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config') { const cr = new Reader(childBlob) if (cr.remaining >= 4) cr.int32() // use_code const gv = { init: cr.lpString(), frame: cr.lpString(), beat: '' } if (gv.init.trim() || gv.frame.trim()) { children.push({ id, apeId, kind: 'globalvars', enabled: true, gv }) } continue } children.push(parseEffect(id, apeId, childBlob)) } return { enabled, children } } function parseEffect(id: number, apeId: string | null, blob: Uint8Array): Effect { const base = { id, apeId, enabled: true } as Effect if (id === ID_SUPERSCOPE) { const r = new Reader(blob) const code = { init: '', frame: '', beat: '', point: '' } let spectrumSource = false let drawMode = 1 const colors: number[] = [] const ver = r.byte() if (ver === 1) { code.point = r.lpString() code.frame = r.lpString() code.beat = r.lpString() code.init = r.lpString() } else { // ancient fixed 1024-byte block if (blob.length >= 1025) { const buf = latin1(blob.subarray(1, 1025)) code.point = buf.slice(0, 256).split('\0')[0] code.frame = buf.slice(256, 512).split('\0')[0] code.beat = buf.slice(512, 768).split('\0')[0] code.init = buf.slice(768, 1024).split('\0')[0] } } if (r.remaining >= 4) { const chsrc = r.int32() spectrumSource = (chsrc & 0b100) !== 0 if (r.remaining >= 4) { const ncolors = r.int32() if (ncolors <= 16) { for (let i = 0; i < ncolors && r.remaining >= 4; i++) colors.push(r.int32() >>> 0) } if (r.remaining >= 4) drawMode = r.int32() } } return { ...base, kind: 'superscope', code, spectrumSource, drawMode, colors } } if (id >= 0x4000 && apeId === APE_TEXER2) { const r = new Reader(blob) r.int32() // version r.bytes(260) // image path r.int32() // resize r.int32() // wrap r.int32() // colorize r.int32() // unused const code = { init: r.lpString(), frame: r.lpString(), beat: r.lpString(), point: r.lpString() } return { ...base, kind: 'texer2', code, spectrumSource: false, drawMode: 0, colors: [] } } if (id === ID_EFFECTLIST || (id >= 0x4000 && apeId === 'AVS 2.8+ Effect List Config')) { const { enabled, children } = parseEffectListBody(blob) return { ...base, kind: 'effectlist', enabled, children } } if (id >= 0x4000 && apeId === APE_EELTRANS) { const r = new Reader(blob) if (r.remaining >= 16) { r.int32(); r.int32(); r.int32(); r.int32() return { ...base, kind: 'eeltrans', transCode: r.ntRest() } } return { ...base, kind: 'other' } } if (id >= 0x4000 && apeId === APE_GLOBALVARS) { const r = new Reader(blob) if (r.remaining >= 28) { r.int32() // load time r.bytes(24) // unused const gv = { init: ntAt(r), frame: ntAt(r), beat: ntAt(r) } return { ...base, kind: 'globalvars', gv } } return { ...base, kind: 'other' } } if (id === ID_COMMENT) { const r = new Reader(blob) return { ...base, kind: 'comment', comment: r.lpString() } } return { ...base, kind: 'other' } } function ntAt(r: Reader): string { // null-terminated string starting at reader position (within the blob) const start = r.pos const d = r.data let end = start while (end < d.length && d[end] !== 0) end++ r.pos = Math.min(end + 1, d.length) return latin1(d.subarray(start, end)) } function parseAvsFile(path: string): Effect[] { const data = new Uint8Array(readFileSync(path)) const magic = 'Nullsoft AVS Preset 0.2\x1a' const head = latin1(data.subarray(0, magic.length)) if (head !== magic) throw new Error(`bad magic: ${JSON.stringify(head.slice(0, 30))}`) const r = new Reader(data.subarray(magic.length)) r.byte() // clear flag const effects: Effect[] = [] while (r.remaining > 8) { const id = r.int32() let apeId: string | null = null if (id >= 0x4000) apeId = latin1(r.bytes(32)).split('\0')[0] const len = r.int32() if (len < 0 || len > r.remaining) break effects.push(parseEffect(id, apeId, r.bytes(len))) } return effects } // --------------------------------------------------------------------------- // AVSTrans #define macro preprocessor // --------------------------------------------------------------------------- interface Macro { name: string params: string[] | null // null = object-like body: string } /** AVSTrans built-in constants (available even without an AVSTrans module). */ const AVSTRANS_BUILTINS: Macro[] = [ { name: '$pi', params: null, body: '3.14159265358979' }, { name: '$twopi', params: null, body: '6.28318530717959' }, { name: '$e', params: null, body: '2.71828182845905' }, { name: '$phi', params: null, body: '1.61803398874989' }, ] function extractDefines(sources: string[]): { macros: Macro[]; failNote: string | null } { const macros: Macro[] = [] let failNote: string | null = null const defineRe = /^[ \t]*#define\s+(.+)$/gm for (const src of sources) { if (!src.includes('#define')) continue // join backslash line-continuations first const joined = src.replace(/\\\r?\n/g, ' ') const matches = joined.match(defineRe) ?? [] for (const m of matches) { const rest = m.replace(/^[ \t]*#define\s+/, '').trim() const fn = rest.match(/^([A-Za-z0-9_$]+)\s*\(([^)]*)\)\s*([\s\S]*)$/) if (fn) { const params = fn[2].split(',').map((p) => p.trim()).filter(Boolean) macros.push({ name: fn[1], params, body: fn[3] }) } else { const obj = rest.match(/^([A-Za-z0-9_$]+)\s+([\s\S]*)$/) if (obj) macros.push({ name: obj[1], params: null, body: obj[2] }) else failNote = 'unparsed #define' } } } // user defines override the AVSTrans built-ins const byName = new Map(macros.map((m) => [m.name, m])) for (const b of AVSTRANS_BUILTINS) if (!byName.has(b.name)) macros.push(b) return { macros, failNote } } /** Split a macro CALL starting at `openPos` (must be '('). */ function readCallArgs(src: string, openPos: number): { args: string[]; end: number } | null { if (src[openPos] !== '(') return null let depth = 0 let start = openPos + 1 const args: string[] = [] for (let i = openPos; i < src.length; i++) { const c = src[i] if (c === '(') depth++ else if (c === ')') { depth-- if (depth === 0) { args.push(src.slice(start, i)) return { args, end: i + 1 } } } else if (c === ',' && depth === 1) { args.push(src.slice(start, i)) start = i + 1 } } return null } function applyMacros(code: string, macros: Macro[], depth = 0): string { if (depth > 24 || !macros.length) return code let out = code let changed = false for (const macro of macros) { const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.name)}($|[^A-Za-z0-9_(]|\\()`, 'g') let result = '' let last = 0 let m: RegExpExecArray | null let macroChanged = false while ((m = re.exec(out))) { const at = m.index + m[1].length // skip #define lines themselves const lineStart = out.lastIndexOf('\n', at) + 1 const lineHead = out.slice(lineStart, at).trimStart() if (lineHead.startsWith('#')) continue if (macro.params) { const call = readCallArgs(out, at + macro.name.length) if (!call || call.args.length !== macro.params.length) continue let body = macro.body // token-concat ##param## first, then plain params for (let i = 0; i < macro.params.length; i++) { body = body.split(`##${macro.params[i]}##`).join(call.args[i].trim()) } for (let i = 0; i < macro.params.length; i++) { body = body.replace(new RegExp(`(^|[^A-Za-z0-9_$])${escapeRe(macro.params[i])}($|[^A-Za-z0-9_])`, 'g'), (_s, pre, post) => `${pre}(${call.args[i].trim()})${post}`) } result += out.slice(last, at) + `(${body})` last = call.end } else { result += out.slice(last, at) + `(${macro.body})` last = at + macro.name.length } macroChanged = true re.lastIndex = last } if (macroChanged) { result += out.slice(last) out = result changed = true } } if (changed) return applyMacros(out, macros, depth + 1) return out } function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function stripDirectives(code: string): string { return code .split('\n') .filter((l) => !/^\s*#/.test(l)) .join('\n') } // --------------------------------------------------------------------------- // code translation // --------------------------------------------------------------------------- function translateCode(code: string, spectrumSource: boolean): string { let out = code // linesize -> size (AVS per-point line width) out = out.replace(/\blinesize\b/g, 'size') // AVS rand(n) returns an integer 0..n-1 — our deterministic rand(seed) differs out = out.replace(/\brand\b/g, 'rnd') // spectrum-source scopes: v should read the spectrum (AVS: byte/128-1) if (spectrumSource) out = out.replace(/\bv\b/g, '(sv*2-1)') return out } function assignsVar(code: string, name: string): boolean { return new RegExp(`(^|[^A-Za-z0-9_])${name}\\s*=[^=]`).test(code) } // --------------------------------------------------------------------------- // candidate collection // --------------------------------------------------------------------------- interface Candidates { scopes: Array eeltrans: string[] gv: Array<{ init: string; frame: string; beat: string }> comment: string | null otherKinds: Set } function collect(effects: Effect[], into: Candidates): void { for (const e of effects) { if (!e.enabled) continue switch (e.kind) { case 'superscope': case 'texer2': into.scopes.push(e as Effect & { kind: 'superscope' | 'texer2' }) break case 'eeltrans': if (e.transCode) into.eeltrans.push(e.transCode) break case 'globalvars': if (e.gv) into.gv.push(e.gv) break case 'comment': if (!into.comment && e.comment) into.comment = e.comment break case 'effectlist': if (e.children) collect(e.children, into) break default: into.otherKinds.add(describeEffect(e)) } } } const BUILTIN_NAMES: Record = { 0: 'Simple', 1: 'DotPlane', 2: 'OscilloscopeStar', 3: 'Fadeout', 4: 'BlitterFeedback', 5: 'OnBeatClear', 6: 'Blur', 7: 'BassSpin', 8: 'MovingParticle', 9: 'RotoBlitter', 10: 'SVP', 11: 'Colorfade', 12: 'ColorClip', 13: 'RotStar', 14: 'Ring', 15: 'Movement', 16: 'Scatter', 17: 'DotGrid', 18: 'BufferSave', 19: 'DotFountain', 20: 'Water', 22: 'Brightness', 23: 'Interleave', 24: 'Grain', 25: 'ClearScreen', 26: 'Mirror', 27: 'Starfield', 29: 'Bump', 30: 'Mosaic', 31: 'WaterBump', 32: 'Video', 33: 'CustomBPM', 34: 'Picture', 35: 'DynamicDistanceModifier', 37: 'Invert', 38: 'UniqueTone', 39: 'Timescope', 40: 'SetRenderMode', 41: 'Interferences', 42: 'DynamicShift', 43: 'DynamicMovement', 44: 'FastBright', 45: 'ColorModifier', } function describeEffect(e: Effect): string { if (e.apeId) return e.apeId return BUILTIN_NAMES[e.id] ?? `effect#${e.id}` } // --------------------------------------------------------------------------- // fallback templates (for presets whose visuals are movement/APE driven) // --------------------------------------------------------------------------- function fallbackTemplate(seed: number, name: string): { content: string; style: string } { const styles = [ { style: 'pulse rings', mode: 'lines', content: `name: ${JSON.stringify(name)} mode: lines blend: additive persist: 0.88 linewidth: 2 [init] n = 720 [frame] hue = hue + dt * (0.06 + mid * 0.2) travel = travel + dt * (0.3 + bass_att * 1.4) [beat] flash = 1 [point] g = floor(i * 9) gi = frac(i * 9) ph = frac(rand(g * 5.1 + 1) + travel * (0.6 + rand(g + 17) * 0.8)) a = gi * 6.28318 rad = ph * (0.25 + bass * 0.6 + flash * 0.08) x = sin(a) * rad / aspect y = cos(a) * rad fade = sin(ph * 3.14159) red = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7)) green = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 2.09)) blue = fade * (0.3 + 0.7 * sin(hue * 6.28318 + g * 0.7 + 4.19)) skip = if(above(ph, 0.95), 1, 0) `, }, { style: 'tunnel', mode: 'lines', content: `name: ${JSON.stringify(name)} mode: lines blend: additive persist: 0.9 linewidth: 1.6 [init] n = 900 [frame] t = t + dt * (0.25 + bass_att * 1.5) hue = hue + dt * 0.1 [point] ring = floor(i * 24) ri = frac(i * 24) z = frac(ring / 24 + t) rad = 0.06 + z * 0.9 a = ri * 6.28318 + ring * 0.35 + sin(t * 0.7) * 2 x = sin(a) * rad * (0.55 + sv * 0.9) / aspect y = cos(a) * rad * (0.55 + sv * 0.9) f = (1 - z) * (0.35 + bass * 0.65) red = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4)) green = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 2.09)) blue = f * (0.45 + 0.55 * sin(hue * 6.28318 + z * 4 + 4.19)) `, }, { style: 'starfield', mode: 'dots', content: `name: ${JSON.stringify(name)} mode: dots blend: additive persist: 0.82 linewidth: 2.5 [init] n = 850 [frame] travel = travel + dt * (0.2 + vol_att * 1.6) hue = hue + dt * 0.05 [point] a = rand(i * 7.3) * 6.28318 z = frac(rand(i * 3.1 + 5) + travel * (0.5 + rand(i + 9) * 0.7)) rad = z * z * 1.1 x = sin(a) * rad / aspect y = cos(a) * rad tw = 0.4 + 0.6 * sin(z * 40 + time * 3) * (0.5 + treb) red = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3)) green = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 2.09)) blue = tw * (0.5 + 0.5 * sin(hue * 6.28318 + i * 3 + 4.19)) size = 1 + z * 3 * (0.5 + bass) skip = if(above(z, 0.97), 1, 0) `, }, { style: 'lattice', mode: 'dots', content: `name: ${JSON.stringify(name)} mode: dots blend: additive persist: 0.78 linewidth: 2.5 [init] cols = 36 n = cols * cols [frame] rot = rot + dt * (0.05 + mid * 0.4) hue = hue + dt * (0.04 + treb * 0.1) [point] cx = floor(i * cols) / (cols - 1) cy = frac(i * cols) h = sv * 0.35 ux = (cx - 0.5) * 1.6 uy = (cy - 0.5) * 1.6 rx = ux * cos(rot) - uy * sin(rot) ry = ux * sin(rot) + uy * cos(rot) x = (rx / aspect) * (1 - h * 0.7) y = ry * (1 - h * 0.7) lum = 0.25 + h * 2.2 red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3)) green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 2.09)) blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + cx * 3 + 4.19)) size = 1.2 + h * 5 `, }, { style: 'swirl', mode: 'lines', content: `name: ${JSON.stringify(name)} mode: lines blend: additive persist: 0.9 linewidth: 1.6 [init] n = 1100 [frame] t = t + dt * (0.3 + bass_att * 1.8) hue = hue + dt * 0.08 + beat * 0.01 [point] arm = floor(i * 5) fa = frac(i * 5) d = fa ang = arm * 1.2566 + d * (1.4 + mid * 3) * 3.14159 + t rad = (0.05 + d * d * 1.15) * (0.7 + bass * 0.5) x = sin(ang) * rad / aspect y = cos(ang) * rad red = (0.3 + 0.7 * d) * (0.3 + bass) green = (0.3 + 0.7 * d) * (0.25 + mid) blue = (0.3 + 0.7 * d) * (0.2 + treb + hue * 0.2) skip = if(below(fa, 0.04), 1, 0) `, }, { style: 'kaleido', mode: 'lines', content: `name: ${JSON.stringify(name)} mode: lines blend: additive persist: 0.86 linewidth: 1.8 [init] n = 560 segs = 8 [frame] rot = rot + dt * (0.08 + mid_att * 0.5) hue = hue + dt * (0.07 + treb * 0.08) [beat] flash = 1 [point] seg = floor(i * segs) si = frac(i * segs) a0 = si * 6.28318 / segs + rot amp = 0.12 + sv * 0.55 + bass * 0.25 + flash * 0.05 rad = 0.15 + amp * (0.4 + 0.6 * sin(a0 * 6 + time * 2)) a = seg * 6.28318 / segs + a0 * if(below(seg % 2, 1), 1, -1) x = sin(a) * rad / aspect y = cos(a) * rad lum = 0.35 + amp * 1.2 red = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg)) green = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 2.09)) blue = lum * (0.6 + 0.4 * sin(hue * 6.28318 + seg + 4.19)) skip = if(above(si, 0.96), 1, 0) `, }, ] return styles[seed % styles.length] } // --------------------------------------------------------------------------- // smoke test — runs the generated script headlessly through the real engine env // --------------------------------------------------------------------------- function smokeTest(content: string): { ok: boolean; error?: string; n: number; ms: number } { const t0 = Date.now() const v = validateScript(content) 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 } resetEelBuffers() AVA_HOOKS.spectrumAt = (p) => 0.25 + 0.25 * Math.sin(p * 18 + 1) AVA_HOOKS.waveAt = (p) => Math.sin(p * 12.56) * 0.8 const env: Record = { n: 400, t: 0, time: 0, frame: 0 } const script = v.script try { script.initProg?.exec(env) } catch (err) { return { ok: false, error: `init: ${String(err)}`, n: 0, ms: Date.now() - t0 } } const n = Math.min(512, Math.max(4, Math.round(env.n ?? 400))) let nan = 0 try { for (let f = 0; f < 36; f++) { env.dt = 1 / 60 env.time = (f + 1) / 60 env.frame = f + 1 AVA_HOOKS.now = () => env.time ?? 0 env.beat = f % 15 === 0 ? 1 : 0 env.bass = 0.3 + 0.3 * Math.sin(f * 0.4) env.mid = 0.3 env.treb = 0.25 env.bass_att = env.bass env.mid_att = 0.3 env.treb_att = 0.25 env.vol = 0.4 env.vol_att = 0.4 env.w = 1280 env.h = 720 env.aspect = 1280 / 720 script.frameProg?.exec(env) if (env.beat) script.beatProg?.exec(env) for (let k = 0; k < n; k++) { env.i = k / (n - 1 || 1) env.v = AVA_HOOKS.waveAt(env.i) env.sv = AVA_HOOKS.spectrumAt(env.i) env.x = 0; env.y = 0; env.red = 1; env.green = 1; env.blue = 1; env.skip = 0; env.size = 2; env.alpha = 1 script.pointProg?.exec(env) if (!Number.isFinite(env.x) || !Number.isFinite(env.y)) nan++ } } } catch (err) { return { ok: false, error: `runtime: ${String(err)}`, n, ms: Date.now() - t0 } } const ms = Date.now() - t0 // some presets deliberately NaN points (skip via NaN) — allow up to 40% if (nan > n * 36 * 0.4) return { ok: false, error: `${nan} NaN points of ${n * 36}`, n, ms } return { ok: true, n, ms } } // --------------------------------------------------------------------------- // name / author extraction // --------------------------------------------------------------------------- function extractNameAuthor(filename: string): { name: string; author: string } { let base = filename.replace(/\.avs$/i, '') base = base.replace(/^\d{1,2}\s+/, '') // "01 dynamic duo - ..." numbering // "Author - Name" (spaces around dash) const spaced = base.match(/^(.+?)\s+-\s+(.+)$/) if (spaced) return { author: spaced[1].trim(), name: spaced[2].trim() } // "Author-Name" (no spaces — e.g. "zxe-Urantia", "Degnic-Adrealine rush") const tight = base.match(/^([A-Za-z0-9_&']+)-([A-Za-z].+)$/) if (tight) return { author: tight[1].trim(), name: tight[2].trim() } return { author: '', name: base } } function hashStr(s: string): number { let h = 2166136261 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i) h = Math.imul(h, 16777619) } return Math.abs(h) } // --------------------------------------------------------------------------- // the converter // --------------------------------------------------------------------------- export { parseAvsFile, collect, applyMacros, extractDefines, translateCode, smokeTest, extractNameAuthor, fallbackTemplate, convertOne } export type { Effect, Candidates, OutputPreset, Macro } interface OutputPreset { folder: string name: string author: string description: string originalFile: string content: string } function convertOne(file: string, relDir: string, absPath: string): { preset: OutputPreset; status: 'ported' | 'fallback'; note: string } { const { name, author } = extractNameAuthor(file) const effects = parseAvsFile(absPath) const cands: Candidates = { scopes: [], eeltrans: [], gv: [], comment: null, otherKinds: new Set() } collect(effects, cands) const folder = 'root/embedded/' + relDir const originalFile = `/avs-originals/embedded/${relDir}/${file}` const desc = (extra: string): string => { const by = author ? ` by ${author}` : '' const src = `Auto-ported from the Winamp AVS preset "${file}"${by}.` return extra ? `${src} ${extra}` : src } const makeFallback = (reason: string) => { const tpl = fallbackTemplate(hashStr(name), name) return { preset: { folder, name, author, 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.`), originalFile, content: tpl.content, }, status: 'fallback' as const, note: `${reason} → ${tpl.style} stand-in`, } } // pick the richest point program const scored = cands.scopes .map((s) => ({ s, score: (s.code?.point.length ?? 0) + (s.code?.frame.length ?? 0) * 0.3 })) .sort((a, b) => b.score - a.score) if (!scored.length || (scored[0].s.code?.point.length ?? 0) < 12) { const reason = cands.otherKinds.size ? Array.from(cands.otherKinds).slice(0, 3).join(', ') : 'no codeable scope' return makeFallback(reason) } const scope = scored[0].s const code = scope.code! // reject trivial scopes (e.g. a 2-point checker) — they are not the real // visual. If init pins a tiny literal n and the frame code never sets n, // the preset's meat is elsewhere (movements / APEs). const initNLit = code.init.match(/(^|[^A-Za-z0-9_])n\s*=\s*([0-9.]+)\s*;/) const frameSetsN = assignsVar(code.frame, 'n') if (initNLit && !frameSetsN && parseFloat(initNLit[2]) < 8) { return makeFallback('a trivial scope (the real work is movement/APE driven)') } // macro preprocessing (AVSTrans defines + local defines) const macroSources = [...cands.eeltrans, code.init, code.frame, code.beat, code.point] const { macros } = extractDefines(macroSources) let init = code.init let frame = code.frame let beat = code.beat let point = code.point let gvNote = '' if (macros.length) { init = applyMacros(init, macros) frame = applyMacros(frame, macros) beat = applyMacros(beat, macros) point = applyMacros(point, macros) } // merge Jheriko Global register-setters as a preamble (sets reg00=dt etc.) if (cands.gv.length) { const gv = cands.gv[0] const gvi = stripDirectives(applyMacros(gv.init, macros)) const gvf = stripDirectives(applyMacros(gv.frame, macros)) const gvb = stripDirectives(applyMacros(gv.beat, macros)) if (gvi.trim()) init = `${gvi}\n${init}` if (gvf.trim()) frame = `${gvf}\n${frame}` if (gvb.trim()) beat = `${gvb}\n${beat}` gvNote = ' + global register code' } init = stripDirectives(init) frame = stripDirectives(frame) beat = stripDirectives(beat) point = stripDirectives(point) // token rewrites const spectrum = scope.spectrumSource === true init = translateCode(init, spectrum) frame = translateCode(frame, spectrum) beat = translateCode(beat, spectrum) point = translateCode(point, spectrum) // AVS `t` is auto-advancing time — if the code never assigns t, map reads to `time` const allCode = `${init}\n${frame}\n${beat}\n${point}` if (!assignsVar(allCode, 't')) { init = init.replace(/\bt\b/g, 'time') frame = frame.replace(/\bt\b/g, 'time') beat = beat.replace(/\bt\b/g, 'time') point = point.replace(/\bt\b/g, 'time') } // default n if never set if (!assignsVar(allCode, 'n')) init = `n = 400\n${init}` // color cycling fallback when the point code never sets colors const setsColors = assignsVar(point, 'red') || assignsVar(point, 'green') || assignsVar(point, 'blue') let colorFrame = '' let colorPoint = '' if (!setsColors && scope.colors && scope.colors.length) { const cols = scope.colors.map((c) => ({ r: ((c >> 16) & 255) / 255, g: ((c >> 8) & 255) / 255, b: (c & 255) / 255, })) const gen = cols .map((c, i) => `if(avsi==${i}, avsr=${c.r.toFixed(3)}; avsg=${c.g.toFixed(3)}; avsb=${c.b.toFixed(3)};)`) .join('\n') colorFrame = `avsc = avsc + 1;\navsp = avsc % ${cols.length * 64};\navsi = floor(avsp / 64);\navsf = (avsp % 64) / 64;\n${gen}` const c1 = cols[0] colorFrame += `\nif(avsi == ${cols.length - 1}, avsr2=${c1.r.toFixed(3)}; avsg2=${c1.g.toFixed(3)}; avsb2=${c1.b.toFixed(3)};)` for (let i = 0; i < cols.length - 1; i++) { 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)};)` } colorFrame += `\navsr = avsr * (1 - avsf) + avsr2 * avsf;\navsg = avsg * (1 - avsf) + avsg2 * avsf;\navsb = avsb * (1 - avsf) + avsb2 * avsf;` colorPoint = 'red = avsr;\ngreen = avsg;\nblue = avsb;' } else if (!setsColors) { colorFrame = 'avsh = avsh + dt * 0.15;' 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);' } const mode = scope.kind === 'texer2' ? 'dots' : scope.drawMode === 0 ? 'dots' : 'lines' const linewidth = mode === 'dots' ? 3 : 2 const content = `# Auto-ported from "${file}" (Winamp AVS${author ? ` / ${author}` : ''}) # ${scope.kind === 'texer2' ? 'Texer II' : 'SuperScope'} point program${gvNote}${spectrum ? ', spectrum source' : ''}${macros.length ? `, ${macros.length} AVSTrans macros expanded` : ''}. name: ${JSON.stringify(name)} author: ${JSON.stringify(author)} mode: ${mode} blend: additive persist: ${mode === 'dots' ? 0.78 : 0.88} linewidth: ${linewidth} ysign: -1 [init] ${init.trim() || 'n = 400'} [frame] ${[frame.trim(), colorFrame].filter(Boolean).join('\n')} [beat] ${beat.trim() || 'flash = 1'} [point] ${[point.trim(), colorPoint].filter(Boolean).join('\n')} ` const smoke = smokeTest(content) if (!smoke.ok) { return makeFallback(`unportable scope code (${smoke.error})`) } const perf = smoke.ms > 900 ? ' (heavy script — point count clamped)' : '' if (smoke.ms > 900) { // clamp n for heavy scripts so the browser keeps 60fps const patched = content.replace(/\[init\]\n/, `[init]\nn = if(above(n, 2048), 2048, n)\n`) const recheck = smokeTest(patched) if (recheck.ok) { return { preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.${perf}`), originalFile, content: patched }, status: 'ported', note: `ok (n clamped, ${smoke.ms}ms → ${recheck.ms}ms)`, } } } return { preset: { folder, name, author, description: desc(`Original ${scope.kind} code, machine-translated to the AVA script dialect.`), originalFile, content }, status: 'ported', note: `ok (${smoke.ms}ms, n=${smoke.n})`, } } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- const inputRoot = process.argv[2] ?? '/tmp/avsdrive/raw' const outDir = 'tools/converted' const originalsDir = 'public/avs-originals/embedded' const SKIP_FOLDERS = new Set(['Synth-C']) // hand-adapted versions ship in BUILTIN_PRESETS function walk(dir: string): string[] { const out: string[] = [] for (const entry of readdirSync(dir)) { const p = join(dir, entry) if (statSync(p).isDirectory()) { if (SKIP_FOLDERS.has(entry)) continue out.push(...walk(p)) } else if (entry.toLowerCase().endsWith('.avs')) out.push(p) } return out } const isMain = typeof process !== 'undefined' && process.argv[1] && process.argv[1].includes('convert-avs') if (!isMain) { // imported as a library — skip main } else { main() } function main() { const files = walk(inputRoot).sort() console.log(`converting ${files.length} .avs files from ${inputRoot}`) const results: OutputPreset[] = [] const report: Array<{ file: string; status: string; note: string }> = [] let ported = 0 let fallback = 0 for (const abs of files) { const rel = relative(inputRoot, abs) const relDir = dirname(rel) === '.' ? '' : dirname(rel) const file = basename(rel) try { const r = convertOne(file, relDir, abs) results.push(r.preset) report.push({ file: rel, status: r.status, note: r.note }) if (r.status === 'ported') ported++ else fallback++ console.log(`${r.status === 'ported' ? '✔' : '○'} ${rel} — ${r.note}`) // keep a copy of the generated .ava for reference const destAva = join(outDir, relDir, file.replace(/\.avs$/i, '.ava')) mkdirSync(dirname(destAva), { recursive: true }) writeFileSync(destAva, r.preset.content) // copy the original binary const destOrig = join(originalsDir, relDir, file) mkdirSync(dirname(destOrig), { recursive: true }) copyFileSync(abs, destOrig) } catch (err) { report.push({ file: rel, status: 'error', note: String(err) }) fallback++ console.log(`✘ ${rel} — ${String(err)}`) } } // emit the TS module const seen = new Set() for (const p of results) { const key = `${p.folder}//${p.name}` if (seen.has(key)) console.log(`!! duplicate name in folder: ${key}`) seen.add(key) } const tsHeader = `// AVA — embedded preset library (AUTO-GENERATED by tools/convert-avs.ts — do not edit) // // ${results.length} Winamp AVS presets from the user's Google Drive collection, // auto-ported to the AVA script dialect by the converter. Ported presets run // the original SuperScope / Texer II code (machine-translated); stand-in // presets are generated visuals for originals that rely on effects the script // engine does not support (movements, APEs). export interface EmbeddedPreset { folder: string name: string author: string description: string originalFile?: string content: string } ` const lines: string[] = [] for (const p of results) { 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 },`) } const ts = tsHeader + `export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [\n${lines.join('\n')}\n]\n` writeFileSync('src/lib/ava/embedded-presets.ts', ts) console.log(`\nDone: ${ported} ported, ${fallback} fallback, ${results.length} total`) console.log(`→ src/lib/ava/embedded-presets.ts (${(ts.length / 1024).toFixed(0)} KB)`) console.log(`→ ${originalsDir}/ (${results.length} .avs originals)`) writeFileSync('tools/convert-report.json', JSON.stringify(report, null, 1)) }