     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<extsize+5][effects...]
    20→//                       child apeId "AVS 2.8+ Effect List Config" =
    21→//                       [int useCode][init][frame]
    22→//     ape "Acko.net: Texer II": [int ver][260b image][int resize][int wrap]
    23→//                       [int colorize][int unused][init][frame][beat][point]
    24→//     ape "Misc: AVSTrans Automation": [int x4][null-term code]
    25→//     ape "Jheriko: Global": [int loadTime][24b pad][nt init][nt frame][nt beat]
    26→//     id 21 Comment: [string]
    27→//   strings: [int32 len][len bytes] (len includes the null when present;
    28→//            empty string = length 0)
    29→
    30→import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs'
    31→import { join, basename, dirname, relative } from 'node:path'
    32→import { validateScript } from '../src/lib/ava/script'
    33→import { AVA_HOOKS, resetEelBuffers } from '../src/lib/ava/expression'
    34→
    35→// ---------------------------------------------------------------------------
    36→// binary reader
    37→// ---------------------------------------------------------------------------
    38→
    39→class Reader {
    40→  pos = 0
    41→  constructor(readonly data: Uint8Array) {}
    42→  get remaining(): number {
    43→    return this.data.length - this.pos
    44→  }
    45→  int32(): number {
    46→    if (this.remaining < 4) throw new Error('out of bytes (int32)')
    47→    const v = this.data[this.pos] | (this.data[this.pos + 1] << 8) | (this.data[this.pos + 2] << 16) | (this.data[this.pos + 3] << 24)
    48→    this.pos += 4
    49→    return v
    50→  }
    51→  byte(): number {
    52→    if (this.remaining < 1) throw new Error('out of bytes (byte)')
    53→    return this.data[this.pos++]
    54→  }
    55→  bytes(n: number): Uint8Array {
    56→    if (n < 0 || this.remaining < n) throw new Error('out of bytes')
    57→    const out = this.data.subarray(this.pos, this.pos + n)
    58→    this.pos += n
    59→    return out
    60→  }
    61→  /** Length-prefixed eel string: [int len][len bytes], cut at first null. */
    62→  lpString(): string {
    63→    const len = this.int32()
    64→    if (len <= 0) return ''
    65→    return latin1(this.bytes(len)).split('\0')[0]
    66→  }
    67→  /** Null-terminated string occupying the rest of the blob. */
    68→  ntRest(): string {
    69→    return latin1(this.bytes(this.remaining)).split('\0')[0]
    70→  }
    71→}
    72→
    73→function latin1(b: Uint8Array): string {
    74→  let s = ''
    75→  for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
    76→  return s
    77→}
    78→
    79→// ---------------------------------------------------------------------------
    80→// effect tree
    81→// ---------------------------------------------------------------------------
    82→
    83→const ID_SUPERSCOPE = 36
    84→const ID_COMMENT = 21
    85→const ID_EFFECTLIST = -2
    86→const APE_TEXER2 = 'Acko.net: Texer II'
    87→const APE_EELTRANS = 'Misc: AVSTrans Automation'
    88→const APE_GLOBALVARS = 'Jheriko: Global'
    89→
    90→interface Effect {
    91→  id: number
    92→  apeId: string | null
    93→  kind: 'superscope' | 'texer2' | 'effectlist' | 'eeltrans' | 'globalvars' | 'comment' | 'other'
    94→  enabled: boolean
    95→  // superscope / texer2
    96→  code?: { init: string; frame: string; beat: string; point: string }
    97→  spectrumSource?: boolean
    98→  drawMode?: number // 0 dots, 1 lines
    99→  colors?: number[] // 0xRRGGBB
   100→  // eeltrans / globalvars
   101→  transCode?: string
   102→  gv?: { init: string; frame: string; beat: string }
   103→  // comment
   104→  comment?: string
   105→  children?: Effect[]
   106→}
   107→
   108→function parseEffectListBody(blob: Uint8Array): { enabled: boolean; children: Effect[] } {
   109→  const r = new Reader(blob)
   110→  let enabled = true
   111→  let ext = 5
   112→  const first = r.byte()
   113→  enabled = (first & 0b10) === 0
   114→  if (first & 0x80) {
   115→    r.byte() // mode0 copy
   116→    r.byte() // input blend
   117→    r.byte() // output blend
   118→    ext = r.byte() + 5
   119→  }
   120→  // extended config ints while pos < ext
   121→  while (r.pos < ext && r.remaining >= 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<Effect & { kind: 'superscope' | 'texer2' }>
   408→  eeltrans: string[]
   409→  gv: Array<{ init: string; frame: string; beat: string }>
   410→  comment: string | null
   411→  otherKinds: Set<string>
   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<number, string> = {
   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<string, number> = { 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<string>()
  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→