☰

Pawprint (PWP): text → Scratch blocks

Pawprint (PWP) is a text language for Scratch. Write whole games (sprites, costumes, backdrops, code) as text and turn them into real blocks in the Scratch editor (also works on TurboWarp).

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Pawprint (PWP): text → Scratch blocks
// @namespace    https://scratch.mit.edu/
// @version      1.8.3
// @icon         data:image/png;base64,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
// @description  Pawprint (PWP) is a text language for Scratch. Write whole games (sprites, costumes, backdrops, code) as text and turn them into real blocks in the Scratch editor (also works on TurboWarp).
// @match        https://scratch.mit.edu/projects/*
// @match        https://turbowarp.org/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  /* =====================================================================
   * 1. VALUE MAPS  (what you type in a dropdown -> Scratch's internal value)
   * ===================================================================== */
  const lc = s => String(s).trim().toLowerCase();
  const MAPS = {
    target: { 'random position': '_random_', 'random': '_random_', 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_' },
    touch: { 'mouse-pointer': '_mouse_', 'mouse pointer': '_mouse_', 'mouse': '_mouse_', 'edge': '_edge_' },
    clone: { 'myself': '_myself_' },
    stage: { 'stage': '_stage_' },
    effect: { color: 'COLOR', colour: 'COLOR', fisheye: 'FISHEYE', whirl: 'WHIRL', pixelate: 'PIXELATE', mosaic: 'MOSAIC', brightness: 'BRIGHTNESS', ghost: 'GHOST' },
    seffect: { pitch: 'PITCH', 'pan left/right': 'PAN', pan: 'PAN' },
    mathop: { abs: 'abs', floor: 'floor', ceiling: 'ceiling', sqrt: 'sqrt', sin: 'sin', cos: 'cos', tan: 'tan', asin: 'asin', acos: 'acos', atan: 'atan', ln: 'ln', log: 'log', 'e ^': 'e ^', 'e^': 'e ^', '10 ^': '10 ^', '10^': '10 ^' },
    current: { year: 'YEAR', month: 'MONTH', date: 'DATE', 'day of week': 'DAYOFWEEK', dayofweek: 'DAYOFWEEK', hour: 'HOUR', minute: 'MINUTE', second: 'SECOND' },
    gt: { loudness: 'LOUDNESS', timer: 'TIMER' },
    pencol: { color: 'color', colour: 'color', saturation: 'saturation', brightness: 'brightness', transparency: 'transparency' },
    numname: { number: 'number', '#': 'number', name: 'name' },
    frontback: { front: 'front', back: 'back' },
    fwdback: { forward: 'forward', backward: 'backward', backwards: 'backward' },
  };
  const DEFAULTS = { target: '_random_', touch: '_mouse_', clone: '_myself_', stage: '_stage_', key: 'space', pencol: 'color' };

  function mapHas(map, raw) { return !!MAPS[map] && Object.prototype.hasOwnProperty.call(MAPS[map], lc(raw)); }
  function mapValue(map, raw) {
    if (map === 'key' || map === 'lower') return lc(raw);
    if (mapHas(map, raw)) return MAPS[map][lc(raw)];
    return String(raw).trim();
  }

  /* =====================================================================
   * 2. BLOCK SPECS
   *   [opcode, shape, ...patterns]
   *   shapes: s=stack h=hat r=reporter b=boolean c=C-block cc=C-cap (forever) cap=cap
   *   slot {NAME:type}
   *     num pnum wnum int ang txt col   -> input with shadow
   *     bool                            -> boolean input
   *     f:map[!]                        -> dropdown field on the block (! = value must be in map)
   *     m:menuOpcode:FIELD[:map]        -> dropdown input (shadow menu block)
   *     var / list / bc                 -> variable / list / broadcast field
   *     bcm                             -> broadcast menu input
   * ===================================================================== */
  const SPECS = [
    // ---- Motion
    ['motion_movesteps', 's', 'move {STEPS:num} steps'],
    ['motion_turnright', 's', 'turn right {DEGREES:num} degrees', 'turn cw {DEGREES:num} degrees', 'turn ↻ {DEGREES:num} degrees', 'turn @turnright {DEGREES:num} degrees'],
    ['motion_turnleft', 's', 'turn left {DEGREES:num} degrees', 'turn ccw {DEGREES:num} degrees', 'turn ↺ {DEGREES:num} degrees', 'turn @turnleft {DEGREES:num} degrees'],
    ['motion_gotoxy', 's', 'go to x: {X:num} y: {Y:num}'],
    ['motion_goto', 's', 'go to {TO:m:motion_goto_menu:TO:target}'],
    ['motion_glidesecstoxy', 's', 'glide {SECS:num} secs to x: {X:num} y: {Y:num}', 'glide {SECS:num} seconds to x: {X:num} y: {Y:num}'],
    ['motion_glideto', 's', 'glide {SECS:num} secs to {TO:m:motion_glideto_menu:TO:target}', 'glide {SECS:num} seconds to {TO:m:motion_glideto_menu:TO:target}'],
    ['motion_pointindirection', 's', 'point in direction {DIRECTION:ang}'],
    ['motion_pointtowards', 's', 'point towards {TOWARDS:m:motion_pointtowards_menu:TOWARDS:target}'],
    ['motion_changexby', 's', 'change x by {DX:num}'],
    ['motion_setx', 's', 'set x to {X:num}'],
    ['motion_changeyby', 's', 'change y by {DY:num}'],
    ['motion_sety', 's', 'set y to {Y:num}'],
    ['motion_ifonedgebounce', 's', 'if on edge, bounce'],
    ['motion_setrotationstyle', 's', 'set rotation style {STYLE:f:lower}'],
    ['motion_xposition', 'r', 'x position'],
    ['motion_yposition', 'r', 'y position'],
    ['motion_direction', 'r', 'direction'],

    // ---- Looks
    ['looks_sayforsecs', 's', 'say {MESSAGE:txt} for {SECS:num} seconds', 'say {MESSAGE:txt} for {SECS:num} secs'],
    ['looks_say', 's', 'say {MESSAGE:txt}'],
    ['looks_thinkforsecs', 's', 'think {MESSAGE:txt} for {SECS:num} seconds', 'think {MESSAGE:txt} for {SECS:num} secs'],
    ['looks_think', 's', 'think {MESSAGE:txt}'],
    ['looks_switchcostumeto', 's', 'switch costume to {COSTUME:m:looks_costume:COSTUME}'],
    ['looks_nextcostume', 's', 'next costume'],
    ['looks_switchbackdroptoandwait', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP} and wait'],
    ['looks_switchbackdropto', 's', 'switch backdrop to {BACKDROP:m:looks_backdrops:BACKDROP}'],
    ['looks_nextbackdrop', 's', 'next backdrop'],
    ['looks_changesizeby', 's', 'change size by {CHANGE:num}'],
    ['looks_setsizeto', 's', 'set size to {SIZE:num} %', 'set size to {SIZE:num}'],
    ['looks_changeeffectby', 's', 'change {EFFECT:f:effect!} effect by {CHANGE:num}'],
    ['looks_seteffectto', 's', 'set {EFFECT:f:effect!} effect to {VALUE:num}'],
    ['looks_cleargraphiceffects', 's', 'clear graphic effects'],
    ['looks_show', 's', 'show'],
    ['looks_hide', 's', 'hide'],
    ['looks_gotofrontback', 's', 'go to {FRONT_BACK:f:frontback} layer'],
    ['looks_goforwardbackwardlayers', 's', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layers', 'go {FORWARD_BACKWARD:f:fwdback} {NUM:int} layer'],
    ['looks_costumenumbername', 'r', 'costume {NUMBER_NAME:f:numname}'],
    ['looks_backdropnumbername', 'r', 'backdrop {NUMBER_NAME:f:numname}'],
    ['looks_size', 'r', 'size'],

    // ---- Sound
    ['sound_playuntildone', 's', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU} until done'],
    ['sound_play', 's', 'start sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}', 'play sound {SOUND_MENU:m:sound_sounds_menu:SOUND_MENU}'],
    ['sound_stopallsounds', 's', 'stop all sounds'],
    ['sound_changeeffectby', 's', 'change {EFFECT:f:seffect!} effect by {VALUE:num}'],
    ['sound_seteffectto', 's', 'set {EFFECT:f:seffect!} effect to {VALUE:num}'],
    ['sound_cleareffects', 's', 'clear sound effects'],
    ['sound_changevolumeby', 's', 'change volume by {VOLUME:num}'],
    ['sound_setvolumeto', 's', 'set volume to {VOLUME:num} %', 'set volume to {VOLUME:num}'],
    ['sound_volume', 'r', 'volume'],

    // ---- Events
    ['event_whenflagclicked', 'h', 'when flag clicked', 'when green flag clicked', 'when @greenflag clicked', 'when ⚑ clicked', 'when gf clicked'],
    ['event_whenkeypressed', 'h', 'when {KEY_OPTION:f:key} key pressed'],
    ['event_whenthisspriteclicked', 'h', 'when this sprite clicked'],
    ['event_whenstageclicked', 'h', 'when stage clicked'],
    ['event_whenbackdropswitchesto', 'h', 'when backdrop switches to {BACKDROP:f}'],
    ['event_whengreaterthan', 'h', 'when {WHENGREATERTHANMENU:f:gt} > {VALUE:num}'],
    ['event_whenbroadcastreceived', 'h', 'when i receive {BROADCAST_OPTION:bc}'],
    ['event_broadcastandwait', 's', 'broadcast {BROADCAST_INPUT:bcm} and wait'],
    ['event_broadcast', 's', 'broadcast {BROADCAST_INPUT:bcm}'],

    // ---- Control
    ['control_wait', 's', 'wait {DURATION:pnum} seconds', 'wait {DURATION:pnum} secs', 'wait {DURATION:pnum}'],
    ['control_repeat', 'c', 'repeat {TIMES:wnum}'],
    ['control_forever', 'cc', 'forever'],
    ['control_if', 'c', 'if {CONDITION:bool} then', 'if {CONDITION:bool}'],
    ['control_wait_until', 's', 'wait until {CONDITION:bool}'],
    ['control_repeat_until', 'c', 'repeat until {CONDITION:bool}'],
    ['control_while', 'c', 'while {CONDITION:bool}'],
    ['control_stop', 'cap', 'stop {STOP_OPTION:f:lower}'],
    ['control_start_as_clone', 'h', 'when i start as a clone'],
    ['control_create_clone_of', 's', 'create clone of {CLONE_OPTION:m:control_create_clone_of_menu:CLONE_OPTION:clone}'],
    ['control_delete_this_clone', 'cap', 'delete this clone'],

    // ---- Sensing
    ['sensing_touchingcolor', 'b', 'touching color {COLOR:col}', 'touching colour {COLOR:col}'],
    ['sensing_touchingobject', 'b', 'touching {TOUCHINGOBJECTMENU:m:sensing_touchingobjectmenu:TOUCHINGOBJECTMENU:touch}'],
    ['sensing_coloristouchingcolor', 'b', 'color {COLOR:col} is touching {COLOR2:col}', 'colour {COLOR:col} is touching {COLOR2:col}'],
    ['sensing_distanceto', 'r', 'distance to {DISTANCETOMENU:m:sensing_distancetomenu:DISTANCETOMENU:touch}'],
    ['sensing_askandwait', 's', 'ask {QUESTION:txt} and wait'],
    ['sensing_answer', 'r', 'answer'],
    ['sensing_keypressed', 'b', 'key {KEY_OPTION:m:sensing_keyoptions:KEY_OPTION:key} pressed'],
    ['sensing_mousedown', 'b', 'mouse down'],
    ['sensing_mousex', 'r', 'mouse x'],
    ['sensing_mousey', 'r', 'mouse y'],
    ['sensing_setdragmode', 's', 'set drag mode {DRAG_MODE:f:lower}'],
    ['sensing_loudness', 'r', 'loudness'],
    ['sensing_timer', 'r', 'timer'],
    ['sensing_resettimer', 's', 'reset timer'],
    ['sensing_current', 'r', 'current {CURRENTMENU:f:current}'],
    ['sensing_dayssince2000', 'r', 'days since 2000'],
    ['sensing_username', 'r', 'username'],

    // ---- Variables & lists (before operators so "[list v] contains" / "length of [list v]" win)
    ['data_setvariableto', 's', 'set {VARIABLE:var} to {VALUE:txt}'],
    ['data_changevariableby', 's', 'change {VARIABLE:var} by {VALUE:num}'],
    ['data_showvariable', 's', 'show variable {VARIABLE:var}'],
    ['data_hidevariable', 's', 'hide variable {VARIABLE:var}'],
    ['data_addtolist', 's', 'add {ITEM:txt} to {LIST:list}'],
    ['data_deletealloflist', 's', 'delete all of {LIST:list}'],
    ['data_deleteoflist', 's', 'delete {INDEX:int} of {LIST:list}'],
    ['data_insertatlist', 's', 'insert {ITEM:txt} at {INDEX:int} of {LIST:list}'],
    ['data_replaceitemoflist', 's', 'replace item {INDEX:int} of {LIST:list} with {ITEM:txt}'],
    ['data_itemoflist', 'r', 'item {INDEX:int} of {LIST:list}'],
    ['data_itemnumoflist', 'r', 'item # of {ITEM:txt} in {LIST:list}'],
    ['data_lengthoflist', 'r', 'length of {LIST:list}'],
    ['data_listcontainsitem', 'b', '{LIST:list} contains {ITEM:txt}'],
    ['data_showlist', 's', 'show list {LIST:list}'],
    ['data_hidelist', 's', 'hide list {LIST:list}'],

    // ---- Operators
    ['operator_add', 'r', '{NUM1:num} + {NUM2:num}'],
    ['operator_subtract', 'r', '{NUM1:num} - {NUM2:num}'],
    ['operator_multiply', 'r', '{NUM1:num} * {NUM2:num}', '{NUM1:num} x {NUM2:num}', '{NUM1:num} × {NUM2:num}'],
    ['operator_divide', 'r', '{NUM1:num} / {NUM2:num}', '{NUM1:num} ÷ {NUM2:num}'],
    ['operator_random', 'r', 'pick random {FROM:num} to {TO:num}'],
    ['operator_gt', 'b', '{OPERAND1:txt} > {OPERAND2:txt}'],
    ['operator_lt', 'b', '{OPERAND1:txt} < {OPERAND2:txt}'],
    ['operator_equals', 'b', '{OPERAND1:txt} = {OPERAND2:txt}'],
    ['operator_and', 'b', '{OPERAND1:bool} and {OPERAND2:bool}'],
    ['operator_or', 'b', '{OPERAND1:bool} or {OPERAND2:bool}'],
    ['operator_not', 'b', 'not {OPERAND:bool}'],
    ['operator_join', 'r', 'join {STRING1:txt} {STRING2:txt}'],
    ['operator_letter_of', 'r', 'letter {LETTER:wnum} of {STRING:txt}'],
    ['operator_length', 'r', 'length of {STRING:txt}'],
    ['operator_contains', 'b', '{STRING1:txt} contains {STRING2:txt}'],
    ['operator_mod', 'r', '{NUM1:num} mod {NUM2:num}'],
    ['operator_round', 'r', 'round {NUM:num}'],
    ['operator_mathop', 'r', '{OPERATOR:f:mathop!} of {NUM:num}'],
    ['sensing_of', 'r', '{PROPERTY:f} of {OBJECT:m:sensing_of_object_menu:OBJECT:stage}'],

    // ---- Pen extension (auto-loaded when used)
    ['pen_clear', 's', 'erase all', 'clear'],
    ['pen_stamp', 's', 'stamp'],
    ['pen_penDown', 's', 'pen down'],
    ['pen_penUp', 's', 'pen up'],
    ['pen_setPenColorToColor', 's', 'set pen color to {COLOR:col}', 'set pen colour to {COLOR:col}'],
    ['pen_changePenColorParamBy', 's', 'change pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} by {VALUE:num}'],
    ['pen_setPenColorParamTo', 's', 'set pen {COLOR_PARAM:m:pen_menu_colorParam:colorParam:pencol} to {VALUE:num}'],
    ['pen_changePenSizeBy', 's', 'change pen size by {SIZE:num}'],
    ['pen_setPenSizeTo', 's', 'set pen size to {SIZE:num}'],
  ];

  const normWord = w => w.toLowerCase().replace(/[,?:]/g, '');
  const SIG = new Map();
  for (const [opcode, shape, ...pats] of SPECS) {
    for (const pat of pats) {
      const args = [], sig = [];
      for (const tok of pat.split(/\s+/)) {
        const m = tok.match(/^\{(\w+):(.+)\}$/);
        if (m) { args.push(parseArgDef(m[1], m[2])); sig.push('_'); }
        else { const w = normWord(tok); if (w) sig.push(w); }
      }
      const key = sig.join(' ');
      if (!SIG.has(key)) SIG.set(key, []);
      SIG.get(key).push({ opcode, shape, args });
    }
  }
  function parseArgDef(name, t) {
    let strict = false;
    if (t.endsWith('!')) { strict = true; t = t.slice(0, -1); }
    const p = t.split(':');
    const d = { name, type: p[0], strict };
    if (d.type === 'f') d.map = p[1];
    if (d.type === 'm') { d.menu = p[1]; d.field = p[2]; d.map = p[3]; }
    return d;
  }

  /* ---------- Extension blocks, read live from whatever extensions are loaded ---------- */
  const DYN_DONE = new Set();
  function addSig(key, spec) { if (!SIG.has(key)) SIG.set(key, []); SIG.get(key).push(spec); }
  function dynArgDef(cat, name, ai) {
    if (ai.menu) {
      const mi = (cat.menuInfo || {})[ai.menu] || {};
      const mapName = `ext_${cat.id}_${ai.menu}`;
      let first = '';
      if (Array.isArray(mi.items)) {
        const o = {};
        for (const it of mi.items) {
          if (typeof it === 'string') { o[lc(it)] = it; if (!first) first = it; }
          else if (it && typeof it === 'object') {
            const v = String(it.value);
            o[lc(String(it.text))] = v; o[lc(v)] = v;
            o[lc(String(it.text).replace(/^\(\d+\)\s*/, ''))] = v; // "(2) Bass Drum" -> also "bass drum"
            if (!first) first = v;
          }
        }
        MAPS[mapName] = o;
      }
      const def = ai.defaultValue !== undefined ? String(ai.defaultValue) : first;
      if (mi.acceptReporters) return { name, type: 'm', menu: `${cat.id}_menu_${ai.menu}`, field: ai.menu, map: mapName, def, strict: false };
      return { name, type: 'f', map: mapName, strict: false };
    }
    const t = { number: 'num', angle: 'ang', string: 'txt', Boolean: 'bool', color: 'col', note: 'note', matrix: 'matrix' }[ai.type] || 'txt';
    return { name, type: t, strict: false };
  }
  function refreshDynamicSpecs(vm) {
    const cats = (vm && vm.runtime && vm.runtime._blockInfo) || [];
    for (const cat of cats) {
      for (const b of cat.blocks || []) {
        const info = b && b.info;
        if (!info || !info.opcode || typeof info.blockType !== 'string') continue;
        const text = Array.isArray(info.text) ? info.text[0] : info.text;
        if (typeof text !== 'string') continue;
        const opcode = `${cat.id}_${info.opcode}`;
        const key = opcode + '|' + text;
        if (DYN_DONE.has(key)) continue;
        DYN_DONE.add(key);
        const bt = info.blockType;
        const shape = bt === 'command' ? (info.isTerminal ? 'cap' : 's')
          : bt === 'reporter' ? 'r'
          : bt === 'Boolean' ? 'b'
          : (bt === 'hat' || bt === 'event') ? 'h'
          : bt === 'conditional' ? 'c'
          : bt === 'loop' ? (info.isTerminal ? 'cc' : 'c')
          : null;
        if (!shape) continue;
        const args = [], sig = [];
        const re = /\[([^\]]+)\]|([^[\]]+)/g;
        let m;
        while ((m = re.exec(text))) {
          if (m[1] !== undefined) {
            const ai = (info.arguments || {})[m[1]] || {};
            if (ai.type === 'image') continue; // icons, not inputs
            args.push(dynArgDef(cat, m[1], ai));
            sig.push('_');
          } else {
            for (const w of m[2].split(/\s+/)) { const nw = normWord(w); if (nw) sig.push(nw); }
          }
        }
        if (!sig.length) continue;
        addSig(sig.join(' '), { opcode, shape, args, branches: info.branchCount || (shape === 'c' || shape === 'cc' ? 1 : 0), ext: cat.id });
      }
    }
  }

  // Friendly names -> built-in extension ids
  const EXT_NAMES = {
    pen: 'pen', music: 'music', video: 'videoSensing', 'video sensing': 'videoSensing', videosensing: 'videoSensing',
    'text to speech': 'text2speech', 'text-to-speech': 'text2speech', text2speech: 'text2speech', tts: 'text2speech',
    translate: 'translate', 'makey makey': 'makeymakey', makeymakey: 'makeymakey',
    'micro:bit': 'microbit', microbit: 'microbit', 'micro bit': 'microbit',
    ev3: 'ev3', 'lego ev3': 'ev3', 'lego mindstorms ev3': 'ev3', boost: 'boost', 'lego boost': 'boost',
    wedo: 'wedo2', wedo2: 'wedo2', 'lego wedo': 'wedo2', 'lego wedo 2.0': 'wedo2', 'lego education wedo 2.0': 'wedo2',
    gdxfor: 'gdxfor', 'force and acceleration': 'gdxfor', 'go direct force & acceleration': 'gdxfor', 'force & acceleration': 'gdxfor',
  };
  function extensionRef(name) {
    const raw = name.trim();
    if (/^https?:\/\//i.test(raw) || raw.startsWith('data:')) return raw;
    const k = lc(raw);
    if (EXT_NAMES[k]) return EXT_NAMES[k];
    if (raw.includes('/')) return `https://extensions.turbowarp.org/${raw.replace(/\.js$/, '')}.js`; // TurboWarp gallery, e.g. Lily/TempVariables2
    return raw; // assume it's already an extension id
  }
  // If a line isn't recognized, these hints tell us which built-in extension it probably needs
  const EXT_HINTS = [
    ['pen', /^(pen (up|down)|erase all|stamp\b|set pen |change pen )/],
    ['music', /^(play drum|rest for|play note|set instrument|set tempo|change tempo)\b|\(tempo\)/],
    ['videoSensing', /\bvideo\b/],
    ['text2speech', /^speak\b|set voice to|set language to/],
    ['translate', /\btranslate\b|\(language\)/],
    ['makeymakey', /pressed in order/],
    ['microbit', /button pressed|^display\b|clear display|\btilted\b|tilt angle|when pin\b/],
    ['gdxfor', /force sensor|\bforce\b|acceleration|spin speed|free fall|when shaken/],
  ];

  /* =====================================================================
   * 3. PARSER
   * ===================================================================== */
  class ParseError extends Error {}
  const PErr = msg => new ParseError(msg);

  // Is the "<" or ">" at s[j] a comparison operator (sitting between two inputs)?
  function isOpAt(s, j, lo) {
    let a = j - 1; while (a >= lo && s[a] === ' ') a--;
    let b = j + 1; while (b < s.length && s[b] === ' ') b++;
    if (a < lo || b >= s.length) return false;
    const spaced = s[j - 1] === ' ' || s[j + 1] === ' ';
    return spaced && ')]>'.includes(s[a]) && /[(\[<\d\-.]/.test(s[b]);
  }
  function findClose(s, i) {
    const open = s[i];
    if (open === '[') {
      const j = s.indexOf(']', i + 1);
      if (j < 0) throw PErr('Missing a closing "]"');
      return j;
    }
    const close = open === '(' ? ')' : '>';
    let j = i + 1;
    while (j < s.length) {
      const c = s[j];
      if (c === '[' || c === '(') { j = findClose(s, j) + 1; continue; }
      if (c === '<' && !isOpAt(s, j, i + 1)) { j = findClose(s, j) + 1; continue; }
      if (c === close && !(c === '>' && isOpAt(s, j, i + 1))) return j;
      j++;
    }
    throw PErr(`Missing a closing "${close}"`);
  }
  function parseParts(s) {
    const parts = [];
    let i = 0, word = '';
    const flush = () => { for (const w of word.split(/\s+/)) if (w) parts.push({ t: 'w', v: w }); word = ''; };
    while (i < s.length) {
      const c = s[i];
      if (c === '(' || c === '[' || (c === '<' && !isOpAt(s, i, 0))) {
        flush();
        const j = findClose(s, i);
        parts.push({ t: c, raw: s.slice(i + 1, j) });
        i = j + 1;
      } else { word += c; i++; }
    }
    flush();
    // Drop scratchblocks "::category" annotations
    const out = [];
    let skip = false;
    for (const p of parts) {
      if (p.t === 'w' && p.v.includes('::')) {
        const pre = p.v.split('::')[0];
        if (pre) out.push({ t: 'w', v: pre });
        skip = true; continue;
      }
      if (skip && p.t === 'w') continue;
      skip = false; out.push(p);
    }
    return out;
  }

  const NUM_RE = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;

  function interpretArg(p, ctx) {
    if (p.t === '[') {
      const m = p.raw.match(/^(.*?)\s+v$/);
      if (m) return { lit: true, value: m[1].trim(), menu: true };
      return { lit: true, value: p.raw, menu: false };
    }
    if (p.t === '(') {
      const raw = p.raw.trim();
      if (raw === '' || NUM_RE.test(raw)) return { lit: true, value: raw, menu: false };
      const m = raw.match(/^(.*?)\s+v$/);
      if (m) return { lit: true, value: m[1].trim(), menu: true };
      if (ctx.procArgs && ctx.procArgs.s.has(raw)) return { block: { kind: 'argr', name: raw, bool: false } };
      const blk = tryParseBlock(raw, 'r', ctx);
      if (blk) return { block: blk };
      // Extra wrapping parentheses like ((a) + (b)) inside ( ) are fine: unwrap them
      const inner = parseParts(raw);
      if (inner.length === 1 && inner[0].t !== 'w') return interpretArg(inner[0], ctx);
      if (ctx.lists.has(raw) && !ctx.vars.has(raw)) return { block: { kind: 'listr', name: raw } };
      return { block: { kind: 'var', name: raw } };
    }
    if (p.t === '<') {
      const raw = p.raw.trim();
      if (!raw) return { empty: true };
      if (ctx.procArgs && ctx.procArgs.b.has(raw)) return { block: { kind: 'argr', name: raw, bool: true } };
      const blk = tryParseBlock(raw, 'b', ctx);
      if (blk) return { block: blk };
      throw PErr(`I don't recognize the boolean block <${raw}>`);
    }
    throw PErr('Unexpected input');
  }

  function argFits(d, a) {
    if (['f', 'var', 'list', 'bc'].includes(d.type)) {
      if (d.type === 'var' && a.block && a.block.kind === 'var') return true;
      if (!a.lit) return false;
      if (d.type === 'list' && !a.menu) return false;
      if (d.strict && !mapHas(d.map, a.value)) return false;
      return true;
    }
    if (d.type === 'bool') return !a.lit;
    return true;
  }

  const SHAPES_FOR = { s: ['s', 'h', 'c', 'cc', 'cap'], r: ['r', 'b'], b: ['b'] };

  function matchOnce(parts, want, ctx) {
    const sig = parts.map(p => (p.t === 'w' ? normWord(p.v) : '_')).filter(Boolean).join(' ');
    const argParts = parts.filter(p => p.t !== 'w');
    const cands = (SIG.get(sig) || []).filter(c => SHAPES_FOR[want].includes(c.shape));
    let args = null;
    for (const c of cands) {
      if (!args) args = argParts.map(p => interpretArg(p, ctx));
      if (c.args.every((d, k) => argFits(d, args[k]))) {
        return { kind: 'std', opcode: c.opcode, shape: c.shape, spec: c, args };
      }
    }
    if (want === 's' && ctx.procs.has(sig)) {
      const proc = ctx.procs.get(sig);
      if (!args) args = argParts.map(p => interpretArg(p, ctx));
      return { kind: 'call', shape: 's', proc, args };
    }
    return null;
  }
  function matchParts(parts, want, ctx) {
    let r = matchOnce(parts, want, ctx);
    if (!r) {
      // Be forgiving: "move 10 steps" -> "move (10) steps"
      const alt = parts.map(p => (p.t === 'w' && NUM_RE.test(p.v) ? { t: '(', raw: p.v } : p));
      if (alt.some((p, i) => p !== parts[i])) r = matchOnce(alt, want, ctx);
    }
    return r;
  }
  function tryParseBlock(raw, want, ctx) { return matchParts(parseParts(raw), want, ctx); }

  function parseDefine(line) {
    let body = line.replace(/^define\s+/i, '');
    let warp = false;
    if (/run without screen refresh/i.test(body)) {
      warp = true;
      body = body.replace(/(::)?\s*run without screen refresh/i, '');
    }
    const parts = parseParts(body);
    const code = [], names = [], types = [], sig = [];
    for (const p of parts) {
      if (p.t === 'w') { code.push(p.v); const w = normWord(p.v); if (w) sig.push(w); }
      else {
        const bool = p.t === '<';
        code.push(bool ? '%b' : '%s');
        names.push(p.raw.trim());
        types.push(bool ? 'b' : 's');
        sig.push('_');
      }
    }
    if (!code.length) throw PErr('"define" needs a block name');
    return {
      proccode: code.join(' '), names, types, warp, sig: sig.join(' '),
      ids: names.map(() => uid()), fromText: true,
    };
  }

  function parseLine(text, ctx, isFirst) {
    if (/^define\s/i.test(text)) {
      const d = parseDefine(text);
      const proc = ctx.procs.get(d.sig) || d;
      if (isFirst) {
        ctx.procArgs = { s: new Set(), b: new Set() };
        proc.names.forEach((n, k) => ctx.procArgs[proc.types[k] === 'b' ? 'b' : 's'].add(n));
      }
      return { kind: 'define', shape: 'h', proc };
    }
    // A lone reporter / boolean on its own line
    if ((text[0] === '(' || text[0] === '<') && findClose(text, 0) === text.length - 1) {
      const a = interpretArg({ t: text[0], raw: text.slice(1, -1) }, ctx);
      if (a.block) return Object.assign({ shape: a.block.shape || 'r' }, a.block);
    }
    const node = matchParts(parseParts(text), 's', ctx);
    if (!node) throw PErr(`I don't recognize the block "${text}"`);
    if (node.opcode === 'control_stop') {
      const v = lc(node.args[0].value);
      if (v.startsWith('other')) { node.shape = 's'; node.hasNext = true; }
    }
    return node;
  }

  function parseStack(L, i, ctx, depth) {
    const nodes = [];
    while (i < L.length) {
      const ln = L[i];
      if (ln.blank) {
        if (depth === 0 && nodes.length) return { nodes, i: i + 1, term: null };
        i++; continue;
      }
      const low = ln.text.toLowerCase();
      if (low === 'end') {
        if (depth === 0) { i++; continue; }
        return { nodes, i: i + 1, term: 'end' };
      }
      if (low === 'else') {
        if (depth === 0) throw lineErr(ln.n, '"else" without an "if"');
        return { nodes, i: i + 1, term: 'else' };
      }
      if (depth === 0 && nodes.length === 0) ctx.procArgs = null;
      let node;
      try { node = parseLine(ln.text, ctx, depth === 0 && nodes.length === 0); }
      catch (e) { throw lineErr(ln.n, e.message); }
      // A hat block in the middle of a script starts a new script
      if (node.shape === 'h' && depth === 0 && nodes.length) return { nodes, i, term: null };
      node.line = ln.n; node.text = ln.text;
      i++;
      if (node.shape === 'c' || node.shape === 'cc') {
        const r = parseStack(L, i, ctx, depth + 1);
        node.sub = r.nodes; i = r.i;
        if (r.term === 'else') {
          const twoBranch = node.spec && node.spec.branches >= 2;
          if (node.opcode !== 'control_if' && !twoBranch) throw lineErr(ln.n, '"else" only works with an "if" block');
          if (node.opcode === 'control_if') node.opcode = 'control_if_else';
          const r2 = parseStack(L, i, ctx, depth + 1);
          node.sub2 = r2.nodes; i = r2.i;
        }
      }
      nodes.push(node);
    }
    return { nodes, i, term: null };
  }

  function lineErr(n, msg) { const e = new ParseError(`Line ${n}: ${msg}`); e.line = n; return e; }

  function validate(nodes, top) {
    nodes.forEach((nd, k) => {
      if (nd.shape === 'h' && !(top && k === 0)) throw lineErr(nd.line, `"${nd.text}" is a hat block, so it has to be at the top of a script`);
      if ((nd.shape === 'r' || nd.shape === 'b') && (!top || nodes.length > 1)) throw lineErr(nd.line, `"${nd.text}" is a reporter, so it can't be stacked with other blocks`);
      if ((nd.shape === 'cap' || nd.shape === 'cc') && k < nodes.length - 1) throw lineErr(nodes[k + 1].line, `Nothing can go after "${nd.text}"`);
      if (nd.sub) validate(nd.sub, false);
      if (nd.sub2) validate(nd.sub2, false);
    });
  }

  function parseText(text, ctx) {
    const L = text.replace(/\r/g, '').split('\n').map((t, idx) => {
      const s = t.replace(/\s\/\/.*$/, '').trim();
      if (s.startsWith('//')) return null; // comment-only lines are skipped (they don't split scripts)
      return s ? { text: s, n: idx + 1 } : { blank: true, n: idx + 1 };
    }).filter(Boolean);
    // Pre-pass: collect custom block definitions so calls can appear anywhere
    for (const ln of L) {
      if (!ln.blank && /^define\s/i.test(ln.text)) {
        try { const d = parseDefine(ln.text); ctx.procs.set(d.sig, d); }
        catch (e) { throw lineErr(ln.n, e.message); }
      }
    }
    const scripts = [];
    let i = 0;
    while (i < L.length) {
      if (L[i].blank) { i++; continue; }
      const r = parseStack(L, i, ctx, 0);
      if (r.nodes.length) { validate(r.nodes, true); scripts.push(r.nodes); }
      if (r.i === i) i++; else i = r.i;
    }
    return scripts;
  }

  /* =====================================================================
   * 4. XML GENERATION (Blockly XML, which Scratch turns into real blocks)
   * ===================================================================== */
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  function uid() {
    const c = '!#%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let s = ''; for (let i = 0; i < 20; i++) s += c[Math.floor(Math.random() * c.length)];
    return s;
  }
  const SHADOW = {
    num: ['math_number', 'NUM'], pnum: ['math_positive_number', 'NUM'], wnum: ['math_whole_number', 'NUM'],
    int: ['math_integer', 'NUM'], ang: ['math_angle', 'NUM'], txt: ['text', 'TEXT'], col: ['colour_picker', 'COLOUR'],
    note: ['note', 'NOTE'], matrix: ['matrix', 'MATRIX'],
  };
  function normColor(v) {
    let m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
    if (m) return '#' + m[1].toLowerCase();
    m = String(v).trim().match(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i);
    if (m) return ('#' + m[1] + m[1] + m[2] + m[2] + m[3] + m[3]).toLowerCase();
    return '#000000';
  }

  function varFieldXml(g, field, name, type) {
    return `<field name="${field}" id="${esc(g.varId(name, type))}" variabletype="${type}">${esc(name)}</field>`;
  }
  const valueXml = (name, inner) => `<value name="${name}">${inner}</value>`;

  function argXml(d, a, g) {
    const blk = a.block ? blockXml(a.block, g, '') : '';
    switch (d.type) {
      case 'f': return `<field name="${d.name}">${esc(mapValue(d.map, a.value))}</field>`;
      case 'var': return varFieldXml(g, d.name, a.lit ? a.value : a.block.name, '');
      case 'list': return varFieldXml(g, d.name, a.value, 'list');
      case 'bc': return varFieldXml(g, d.name, a.value, 'broadcast_msg');
      case 'bool': return blk ? valueXml(d.name, blk) : '';
      case 'bcm': {
        const name = a.lit && a.value ? a.value : 'message1';
        return valueXml(d.name, `<shadow type="event_broadcast_menu">${varFieldXml(g, 'BROADCAST_OPTION', name, 'broadcast_msg')}</shadow>${blk}`);
      }
      case 'm': {
        const v = a.lit ? mapValue(d.map, a.value) : (DEFAULTS[d.map] || d.def || '');
        return valueXml(d.name, `<shadow type="${d.menu}"><field name="${d.field}">${esc(v)}</field></shadow>${blk}`);
      }
      default: {
        const [st, f] = SHADOW[d.type];
        let v = a.lit ? a.value : '';
        if (d.type === 'col') v = normColor(v);
        return valueXml(d.name, `<shadow type="${st}"><field name="${f}">${esc(v)}</field></shadow>${blk}`);
      }
    }
  }

  function procMutation(proc, withNames) {
    let m = `<mutation proccode="${esc(proc.proccode)}" argumentids="${esc(JSON.stringify(proc.ids))}"`;
    if (withNames) {
      m += ` argumentnames="${esc(JSON.stringify(proc.names))}" argumentdefaults="${esc(JSON.stringify(proc.types.map(t => (t === 'b' ? 'false' : ''))))}"`;
    }
    return m + ` warp="${proc.warp ? 'true' : 'false'}"></mutation>`;
  }

  function blockXml(node, g, next) {
    const nx = next ? `<next>${next}</next>` : '';
    g.opcodes.add(node.opcode || node.kind);
    switch (node.kind) {
      case 'var':
        return `<block type="data_variable">${varFieldXml(g, 'VARIABLE', node.name, '')}</block>`;
      case 'listr':
        return `<block type="data_listcontents">${varFieldXml(g, 'LIST', node.name, 'list')}</block>`;
      case 'argr':
        return `<block type="${node.bool ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(node.name)}</field></block>`;
      case 'define': {
        const p = node.proc;
        const args = p.ids.map((id, k) =>
          valueXml(esc(id), `<shadow type="${p.types[k] === 'b' ? 'argument_reporter_boolean' : 'argument_reporter_string_number'}"><field name="VALUE">${esc(p.names[k])}</field></shadow>`)
        ).join('');
        return `<block type="procedures_definition"><value name="custom_block"><shadow type="procedures_prototype">${procMutation(p, true)}${args}</shadow></value>${nx}</block>`;
      }
      case 'call': {
        const p = node.proc;
        const args = p.ids.map((id, k) => {
          const a = node.args[k] || { lit: true, value: '' };
          const blk = a.block ? blockXml(a.block, g, '') : '';
          if (p.types[k] === 'b') return blk ? valueXml(esc(id), blk) : '';
          const sh = p.types[k] === 'n' ? ['math_number', 'NUM'] : ['text', 'TEXT'];
          return valueXml(esc(id), `<shadow type="${sh[0]}"><field name="${sh[1]}">${esc(a.lit ? a.value : '')}</field></shadow>${blk}`);
        }).join('');
        return `<block type="procedures_call">${procMutation(p, false)}${args}${nx}</block>`;
      }
      default: {
        let inner = '';
        if (node.opcode === 'control_stop') inner += `<mutation hasnext="${node.hasNext ? 'true' : 'false'}"></mutation>`;
        node.spec.args.forEach((d, k) => { inner += argXml(d, node.args[k], g); });
        if (node.sub && node.sub.length) inner += `<statement name="SUBSTACK">${stackXml(node.sub, g)}</statement>`;
        if (node.sub2 && node.sub2.length) inner += `<statement name="SUBSTACK2">${stackXml(node.sub2, g)}</statement>`;
        return `<block type="${node.opcode}">${inner}${nx}</block>`;
      }
    }
  }
  function stackXml(nodes, g) {
    let xml = '';
    for (let k = nodes.length - 1; k >= 0; k--) xml = blockXml(nodes[k], g, xml);
    return xml;
  }

  /* =====================================================================
   * 5. EDITOR HOOKUP
   * ===================================================================== */
  function getEditor() {
    let el = document.querySelector('[class*="blocks_blocks"]');
    if (!el) { const inj = document.querySelector('.injectionDiv'); el = inj && inj.parentElement; }
    for (let node = el, up = 0; node && up < 6; up++, node = node.parentElement) {
      const key = Object.keys(node).find(k => k.startsWith('__reactInternalInstance$') || k.startsWith('__reactFiber$'));
      if (!key) continue;
      for (let f = node[key]; f; f = f.return) {
        const sn = f.stateNode;
        if (sn && sn.ScratchBlocks && sn.workspace) return { SB: sn.ScratchBlocks, ws: sn.workspace, vm: sn.props && sn.props.vm };
      }
    }
    return null;
  }

  function xmlToDom(SB, text) {
    if (SB.Xml && typeof SB.Xml.textToDom === 'function') return SB.Xml.textToDom(text);
    if (SB.utils && SB.utils.xml && typeof SB.utils.xml.textToDom === 'function') return SB.utils.xml.textToDom(text);
    const doc = new DOMParser().parseFromString(text, 'text/xml');
    const err = doc.getElementsByTagName('parsererror')[0];
    if (err) throw new Error('Internal XML error: ' + err.textContent);
    return doc.documentElement;
  }

  function buildCtx(ws) {
    const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
    try {
      for (const v of ws.getAllVariables()) {
        if (v.type === '') ctx.vars.add(v.name);
        if (v.type === 'list') ctx.lists.add(v.name);
      }
    } catch (e) { /* ignore */ }
    try {
      for (const b of ws.getAllBlocks()) {
        if (b.type !== 'procedures_prototype') continue;
        const m = b.mutationToDom();
        const proccode = m.getAttribute('proccode');
        const ids = JSON.parse(m.getAttribute('argumentids') || '[]');
        const names = JSON.parse(m.getAttribute('argumentnames') || '[]');
        const toks = proccode.split(' ');
        const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
        const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
        ctx.procs.set(sig, { proccode, ids, names, types, warp: m.getAttribute('warp') === 'true', sig });
      }
    } catch (e) { /* ignore */ }
    return ctx;
  }

  function collectOpcodes(nodes, set) {
    for (const n of nodes) {
      if (n.opcode) set.add(n.opcode);
      (n.args || []).forEach(a => a.block && collectOpcodes([a.block], set));
      if (n.sub) collectOpcodes(n.sub, set);
      if (n.sub2) collectOpcodes(n.sub2, set);
    }
    return set;
  }

  /* ---------- sections: "sprite: Name" / "stage:" ---------- */
  const HEAD_SPRITE = /^sprite\s*:\s*(.+?)\s*$/i;
  const HEAD_STAGE = /^stage\s*:?\s*$/i;
  const DRAW_CMDS = /^(circle|ellipse|oval|rect|rectangle|square|line|polygon|poly|text|outline|center|preset|stamp)\b/i;
  const DIRECTIVE = /^(costume|backdrop|sound|at|position|size|direction|hidden|visible|draggable|extensions?)\s*:\s*(.*)$/i;

  function splitSections(text) {
    const lines = text.replace(/\r/g, '').split('\n');
    const secs = [];
    let cur = { kind: 'current', name: null, line: 0, owned: new Set(), directives: [] };
    secs.push(cur);
    for (let idx = 0; idx < lines.length; idx++) {
      const t = lines[idx].trim();
      let m;
      if ((m = t.match(HEAD_SPRITE))) {
        cur = { kind: 'sprite', name: m[1], line: idx + 1, owned: new Set(), directives: [] };
        secs.push(cur); continue;
      }
      if (HEAD_STAGE.test(t)) {
        cur = { kind: 'stage', name: 'Stage', line: idx + 1, owned: new Set(), directives: [] };
        secs.push(cur); continue;
      }
      if ((m = t.match(DIRECTIVE))) {
        const d = { key: m[1].toLowerCase(), val: m[2].trim(), n: idx + 1, extra: [] };
        if (d.key === 'costume' || d.key === 'backdrop') {
          if (/(^|=\s*)svg\s*$/i.test(d.val) || (/<svg/i.test(d.val) && !/<\/svg>/i.test(d.val))) {
            // everything up to </svg> belongs to this costume
            while (idx + 1 < lines.length) { idx++; d.extra.push({ text: lines[idx], n: idx + 1 }); if (/<\/svg>/i.test(lines[idx])) break; }
          } else if (/(^|=\s*)draw\b/i.test(d.val)) {
            // shape lines right under "draw" belong to this costume
            while (idx + 1 < lines.length && DRAW_CMDS.test(lines[idx + 1].trim())) { idx++; d.extra.push({ text: lines[idx].trim(), n: idx + 1 }); }
          }
        }
        cur.directives.push(d); continue;
      }
      cur.owned.add(idx);
    }
    for (const s of secs) s.text = lines.map((l, i) => (s.owned.has(i) ? l : '//')).join('\n');
    return secs.filter(s => s.kind !== 'current' || s.directives.length || /\S/.test(s.text.replace(/^\/\/$/gm, '')));
  }

  /* ---------- ready-made costume art (presets) ---------- */
  // Pawprint preset costumes. Each preset draws into a 100x100 box, facing right.
  // c = main color, o = dark outline color, l = light highlight color
  const PRESETS = (() => {
    const S = 'stroke-width="3" stroke-linejoin="round" stroke-linecap="round"';
    const shine = (x, y, rx, ry, rot = -30) => `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" transform="rotate(${rot} ${x} ${y})" fill="#fff" opacity=".45"/>`;
    const eye = (x, y, r = 6, look = 1.5) => `<circle cx="${x}" cy="${y}" r="${r}" fill="#fff" stroke="#1b1b2f" stroke-width="2"/><circle cx="${x + look}" cy="${y}" r="${r * 0.5}" fill="#1b1b2f"/><circle cx="${x + look + r * 0.2}" cy="${y - r * 0.25}" r="${r * 0.18}" fill="#fff"/>`;
    return {
      hero: { color: '#3b82f6', draw: (c, o) => `
        <rect x="38" y="70" width="9" height="22" rx="4" fill="#3b3b58" stroke="#1b1b2f" ${S}/>
        <rect x="53" y="70" width="9" height="22" rx="4" fill="#3b3b58" stroke="#1b1b2f" ${S}/>
        <rect x="31" y="40" width="38" height="36" rx="10" fill="${c}" stroke="${o}" ${S}/>
        <rect x="62" y="46" width="10" height="22" rx="5" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="50" cy="26" r="17" fill="#ffd2a6" stroke="#8a5a3c" ${S}/>
        <path d="M33 22 Q38 6 56 9 Q68 12 67 24 Q58 16 46 19 Q38 21 33 22Z" fill="#5a3825" stroke="#3a2214" ${S}/>
        ${eye(57, 27, 4.5, 1.2)}
        <path d="M56 36 Q60 38 63 35" fill="none" stroke="#8a5a3c" stroke-width="2.5" stroke-linecap="round"/>
        ${shine(40, 50, 4, 9, 0)}` },
      robot: { color: '#9ca3af', draw: (c, o) => `
        <line x1="50" y1="14" x2="50" y2="4" stroke="${o}" stroke-width="3"/><circle cx="50" cy="5" r="5" fill="#ef4444" stroke="#7f1d1d" stroke-width="2.5"/>
        <rect x="26" y="14" width="48" height="34" rx="9" fill="${c}" stroke="${o}" ${S}/>
        <rect x="33" y="22" width="34" height="16" rx="6" fill="#1b1b2f"/>
        <circle cx="44" cy="30" r="4" fill="#22d3ee"/><circle cx="58" cy="30" r="4" fill="#22d3ee"/>
        <rect x="30" y="52" width="40" height="30" rx="7" fill="${c}" stroke="${o}" ${S}/>
        <rect x="40" y="59" width="20" height="10" rx="3" fill="#facc15" stroke="${o}" stroke-width="2"/>
        <rect x="35" y="84" width="10" height="10" rx="3" fill="${o}"/><rect x="55" y="84" width="10" height="10" rx="3" fill="${o}"/>
        <rect x="18" y="55" width="10" height="22" rx="5" fill="${c}" stroke="${o}" ${S}/><rect x="72" y="55" width="10" height="22" rx="5" fill="${c}" stroke="${o}" ${S}/>
        ${shine(34, 20, 3, 6, 0)}${shine(37, 58, 3, 6, 0)}` },
      slime: { color: '#22c55e', draw: (c, o) => `
        <path d="M12 86 Q8 60 26 42 Q40 24 54 26 Q76 28 86 52 Q94 70 88 86 Q70 92 50 90 Q28 92 12 86Z" fill="${c}" stroke="${o}" ${S}/>
        ${eye(48, 56, 7)}${eye(68, 56, 7)}
        <path d="M52 72 Q60 78 68 72" fill="none" stroke="${o}" stroke-width="3" stroke-linecap="round"/>
        ${shine(32, 46, 5, 11, 35)}<circle cx="27" cy="62" r="3" fill="#fff" opacity=".45"/>` },
      ghost: { color: '#e5e7eb', draw: (c, o) => `
        <path d="M18 90 L18 44 Q18 12 50 12 Q82 12 82 44 L82 90 L73 82 L64 90 L55 82 L46 90 L37 82 L28 90 Z" fill="${c}" stroke="${o}" ${S}/>
        <ellipse cx="44" cy="44" rx="6" ry="9" fill="#1b1b2f"/><ellipse cx="64" cy="44" rx="6" ry="9" fill="#1b1b2f"/>
        <circle cx="46" cy="40" r="2.2" fill="#fff"/><circle cx="66" cy="40" r="2.2" fill="#fff"/>
        <ellipse cx="55" cy="64" rx="6" ry="7" fill="#1b1b2f"/>
        ${shine(30, 30, 5, 10, 25)}` },
      alien: { color: '#84cc16', draw: (c, o) => `
        <line x1="36" y1="18" x2="28" y2="4" stroke="${o}" stroke-width="3"/><circle cx="28" cy="5" r="4" fill="${c}" stroke="${o}" stroke-width="2.5"/>
        <line x1="64" y1="18" x2="72" y2="4" stroke="${o}" stroke-width="3"/><circle cx="72" cy="5" r="4" fill="${c}" stroke="${o}" stroke-width="2.5"/>
        <rect x="38" y="64" width="24" height="26" rx="10" fill="${c}" stroke="${o}" ${S}/>
        <ellipse cx="50" cy="42" rx="32" ry="28" fill="${c}" stroke="${o}" ${S}/>
        <ellipse cx="38" cy="42" rx="9" ry="12" fill="#1b1b2f" transform="rotate(-20 38 42)"/><ellipse cx="62" cy="42" rx="9" ry="12" fill="#1b1b2f" transform="rotate(20 62 42)"/>
        <circle cx="40" cy="37" r="3" fill="#fff"/><circle cx="64" cy="37" r="3" fill="#fff"/>
        <path d="M44 58 Q50 62 56 58" fill="none" stroke="${o}" stroke-width="2.5" stroke-linecap="round"/>
        ${shine(30, 26, 4, 8, 40)}` },
      ufo: { color: '#a855f7', draw: (c, o) => `
        <path d="M30 50 Q30 22 50 22 Q70 22 70 50Z" fill="#bae6fd" stroke="#0c4a6e" ${S} opacity=".95"/>
        <circle cx="50" cy="40" r="7" fill="#84cc16" stroke="#365314" stroke-width="2"/><circle cx="52" cy="38" r="2" fill="#1b1b2f"/>
        <ellipse cx="50" cy="56" rx="44" ry="14" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="26" cy="57" r="4" fill="#facc15"/><circle cx="50" cy="61" r="4" fill="#facc15"/><circle cx="74" cy="57" r="4" fill="#facc15"/>
        ${shine(38, 30, 3, 7, 35)}` },
      rocket: { color: '#ef4444', draw: (c, o) => `
        <path d="M16 50 L4 40 Q8 50 4 60 Z" fill="#f97316"/><path d="M18 50 L9 44 Q12 50 9 56Z" fill="#facc15"/>
        <path d="M24 38 L12 20 L30 26 L40 38Z" fill="${c}" stroke="${o}" ${S}/><path d="M24 62 L12 80 L30 74 L40 62Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M18 38 L66 38 Q92 42 96 50 Q92 58 66 62 L18 62 Z" fill="#f1f5f9" stroke="#475569" ${S}/>
        <path d="M78 40 Q92 44 96 50 Q92 56 78 60 Z" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="56" cy="50" r="8" fill="#38bdf8" stroke="#475569" stroke-width="3"/>
        ${shine(40, 43, 2.5, 7, 80)}` },
      spaceship: { color: '#38bdf8', draw: (c, o) => `
        <path d="M14 50 L4 44 L8 50 L4 56Z" fill="#f97316"/>
        <path d="M16 22 L44 34 L94 50 L44 66 L16 78 L26 50Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M44 42 L70 50 L44 58 Q38 50 44 42Z" fill="#1e293b" stroke="${o}" stroke-width="2"/>
        <path d="M48 46 L58 49" stroke="#fff" stroke-width="2" opacity=".6" stroke-linecap="round"/>
        ${shine(32, 36, 3, 8, 70)}` },
      car: { color: '#ef4444', draw: (c, o) => `
        <path d="M8 66 L10 52 Q12 46 20 45 L32 44 L42 30 Q45 27 50 27 L70 27 Q75 27 78 31 L86 44 Q94 46 94 54 L94 66Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M45 42 L52 32 L62 32 L62 42Z M66 32 L72 32 L78 42 L66 42Z" fill="#bae6fd" stroke="${o}" stroke-width="2"/>
        <circle cx="28" cy="68" r="11" fill="#1f2937" stroke="#000" stroke-width="2"/><circle cx="28" cy="68" r="4.5" fill="#d1d5db"/>
        <circle cx="74" cy="68" r="11" fill="#1f2937" stroke="#000" stroke-width="2"/><circle cx="74" cy="68" r="4.5" fill="#d1d5db"/>
        <rect x="88" y="50" width="6" height="5" rx="2" fill="#fde047"/>
        ${shine(24, 50, 2.5, 7, 70)}` },
      fish: { color: '#f97316', draw: (c, o) => `
        <path d="M22 50 L6 32 L10 50 L6 68Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M44 28 Q50 16 62 22 L58 30Z" fill="${c}" stroke="${o}" ${S}/>
        <ellipse cx="52" cy="50" rx="36" ry="22" fill="${c}" stroke="${o}" ${S}/>
        <path d="M40 34 Q34 50 40 66 M50 30 Q44 50 50 70" fill="none" stroke="${o}" stroke-width="2" opacity=".35"/>
        ${eye(72, 45, 6)}
        <path d="M84 56 Q80 58 76 56" fill="none" stroke="${o}" stroke-width="2.5" stroke-linecap="round"/>
        ${shine(52, 36, 3, 9, 75)}` },
      bird: { color: '#facc15', draw: (c, o) => `
        <path d="M20 54 L4 46 L8 56 L4 64Z" fill="${c}" stroke="${o}" ${S}/>
        <ellipse cx="46" cy="56" rx="30" ry="24" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="68" cy="38" r="17" fill="${c}" stroke="${o}" ${S}/>
        <path d="M82 36 L96 41 L82 46Z" fill="#f97316" stroke="#9a3412" stroke-width="2" stroke-linejoin="round"/>
        ${eye(73, 33, 5)}
        <path d="M28 52 Q42 40 56 54 Q44 70 28 52Z" fill="${o}" opacity=".35"/>
        <path d="M38 80 L36 92 M50 80 L50 92" stroke="#f97316" stroke-width="3" stroke-linecap="round"/>
        ${shine(58, 28, 3, 6, 40)}` },
      tree: { color: '#16a34a', draw: (c, o) => `
        <rect x="42" y="62" width="16" height="32" rx="3" fill="#92400e" stroke="#451a03" ${S}/>
        <circle cx="32" cy="50" r="20" fill="${c}" stroke="${o}" ${S}/><circle cx="68" cy="50" r="20" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="50" cy="30" r="24" fill="${c}" stroke="${o}" ${S}/><circle cx="50" cy="52" r="20" fill="${c}"/>
        ${shine(40, 20, 5, 10, 40)}` },
      cloud: { color: '#ffffff', draw: (c, o) => `
        <path d="M22 72 Q6 72 8 58 Q10 44 26 46 Q28 28 46 28 Q60 28 64 40 Q72 32 82 38 Q94 46 90 58 Q96 72 80 72Z" fill="${c}" stroke="${o}" ${S}/>
        ${shine(40, 40, 5, 9, 60)}` },
      heart: { color: '#ef4444', draw: (c, o) => `
        <path d="M50 88 Q16 64 10 42 Q6 20 28 16 Q42 14 50 30 Q58 14 72 16 Q94 20 90 42 Q84 64 50 88Z" fill="${c}" stroke="${o}" ${S}/>
        ${shine(28, 34, 5, 10, 35)}<circle cx="24" cy="50" r="3" fill="#fff" opacity=".45"/>` },
      coin: { color: '#facc15', draw: (c, o) => `
        <circle cx="50" cy="50" r="40" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="50" cy="50" r="30" fill="none" stroke="${o}" stroke-width="3" opacity=".5"/>
        <path d="M50 30 L56 44 L71 45 L59 55 L63 70 L50 61 L37 70 L41 55 L29 45 L44 44Z" fill="${o}" opacity=".45"/>
        ${shine(32, 30, 6, 12, 40)}` },
      gem: { color: '#06b6d4', draw: (c, o) => `
        <path d="M28 18 L72 18 L92 40 L50 90 L8 40Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M8 40 L92 40 M28 18 L38 40 L50 90 L62 40 L72 18 M38 40 L50 18 L62 40" fill="none" stroke="${o}" stroke-width="2" opacity=".5"/>
        <path d="M30 22 L36 36 L16 38Z" fill="#fff" opacity=".5"/>` },
      key: { color: '#facc15', draw: (c, o) => `
        <path d="M46 44 L92 44 L92 54 L84 54 L84 64 L76 64 L76 54 L68 54 L68 62 L60 62 L60 54 L46 54Z" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="30" cy="49" r="22" fill="${c}" stroke="${o}" ${S}/><circle cx="30" cy="49" r="9" fill="#fff" fill-opacity="0" stroke="${o}" stroke-width="3"/>
        ${shine(20, 38, 3, 7, 40)}` },
      sword: { color: '#cbd5e1', draw: (c, o) => `
        <path d="M30 70 L80 20 L92 8 L88 22 L38 78Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M36 74 L84 18" stroke="#fff" stroke-width="2" opacity=".6"/>
        <path d="M18 62 L26 54 L46 74 L38 82Z" fill="#b45309" stroke="#451a03" ${S}/>
        <path d="M30 74 L14 90" stroke="#78350f" stroke-width="8" stroke-linecap="round"/>
        <circle cx="12" cy="92" r="5" fill="#facc15" stroke="#451a03" stroke-width="2.5"/>` },
      apple: { color: '#ef4444', draw: (c, o) => `
        <path d="M50 28 Q62 14 78 22 Q96 34 88 62 Q80 90 62 90 Q56 88 50 86 Q44 88 38 90 Q20 90 12 62 Q4 34 22 22 Q38 14 50 28Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M50 28 Q50 16 56 8" fill="none" stroke="#713f12" stroke-width="4" stroke-linecap="round"/>
        <path d="M54 16 Q66 4 78 10 Q68 22 54 16Z" fill="#22c55e" stroke="#14532d" stroke-width="2.5" stroke-linejoin="round"/>
        ${shine(28, 40, 5, 11, 25)}` },
      house: { color: '#f59e0b', draw: (c, o) => `
        <rect x="18" y="44" width="64" height="48" fill="${c}" stroke="${o}" ${S}/>
        <path d="M8 48 L50 12 L92 48Z" fill="#b91c1c" stroke="#450a0a" ${S}/>
        <rect x="42" y="64" width="16" height="28" rx="2" fill="#78350f" stroke="#451a03" ${S}/><circle cx="54" cy="79" r="2" fill="#facc15"/>
        <rect x="24" y="54" width="13" height="13" fill="#bae6fd" stroke="${o}" stroke-width="2.5"/><rect x="63" y="54" width="13" height="13" fill="#bae6fd" stroke="${o}" stroke-width="2.5"/>` },
      flag: { color: '#ef4444', draw: (c, o) => `
        <path d="M26 16 Q46 8 60 18 Q74 28 90 20 L90 54 Q74 62 60 52 Q46 42 26 50Z" fill="${c}" stroke="${o}" ${S}/>
        <rect x="20" y="10" width="7" height="84" rx="3" fill="#94a3b8" stroke="#334155" stroke-width="2.5"/>
        <circle cx="23.5" cy="9" r="5" fill="#facc15" stroke="#713f12" stroke-width="2"/>
        ${shine(44, 24, 3, 8, 70)}` },
      bomb: { color: '#334155', draw: (c, o) => `
        <circle cx="46" cy="58" r="32" fill="${c}" stroke="#0f172a" ${S}/>
        <rect x="54" y="22" width="16" height="12" rx="3" transform="rotate(35 62 28)" fill="#64748b" stroke="#0f172a" stroke-width="2.5"/>
        <path d="M68 22 Q74 10 84 12" fill="none" stroke="#a16207" stroke-width="3" stroke-linecap="round"/>
        <circle cx="86" cy="11" r="6" fill="#facc15"/><circle cx="86" cy="11" r="3" fill="#f97316"/>
        ${shine(32, 44, 5, 10, 40)}` },
      crate: { color: '#b45309', draw: (c, o) => `
        <rect x="10" y="10" width="80" height="80" rx="4" fill="${c}" stroke="${o}" ${S}/>
        <rect x="20" y="20" width="60" height="60" fill="none" stroke="${o}" stroke-width="3"/>
        <path d="M20 20 L80 80 M80 20 L20 80" stroke="${o}" stroke-width="5"/>
        <path d="M14 14 L86 14" stroke="#fff" stroke-width="2" opacity=".35"/>` },
      sun: { color: '#facc15', draw: (c, o) => {
        let rays = '';
        for (let i = 0; i < 12; i++) { const a = i * 30; rays += `<path d="M50 4 L55 18 L45 18Z" transform="rotate(${a} 50 50)" fill="#f97316"/>`; }
        return `${rays}<circle cx="50" cy="50" r="28" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="42" cy="46" r="3" fill="#1b1b2f"/><circle cx="58" cy="46" r="3" fill="#1b1b2f"/>
        <path d="M40 58 Q50 66 60 58" fill="none" stroke="#1b1b2f" stroke-width="3" stroke-linecap="round"/>
        ${shine(38, 34, 4, 8, 40)}`; } },
      mushroom: { color: '#ef4444', draw: (c, o) => `
        <path d="M36 54 L34 86 Q50 92 66 86 L64 54Z" fill="#fef3c7" stroke="#78350f" ${S}/>
        <circle cx="44" cy="70" r="3" fill="#1b1b2f"/><circle cx="56" cy="70" r="3" fill="#1b1b2f"/>
        <path d="M8 56 Q8 14 50 12 Q92 14 92 56 Q50 64 8 56Z" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="30" cy="36" r="7" fill="#fff"/><circle cx="62" cy="28" r="9" fill="#fff"/><circle cx="76" cy="46" r="5" fill="#fff"/><circle cx="46" cy="50" r="4" fill="#fff"/>` },
      spikes: { color: '#94a3b8', draw: (c, o) => `
        <path d="M6 90 L18 40 L30 90 L42 30 L54 90 L66 40 L78 90 L88 50 L94 90Z" fill="${c}" stroke="${o}" ${S}/>
        <path d="M18 44 L14 70 M42 34 L38 66 M66 44 L62 70" stroke="#fff" stroke-width="2" opacity=".5"/>` },
      platform: { color: '#16a34a', draw: (c, o) => `
        <rect x="2" y="40" width="96" height="30" rx="4" fill="#92400e" stroke="#451a03" ${S}/>
        <path d="M2 48 Q2 34 10 34 L90 34 Q98 34 98 48 Q90 52 82 46 Q74 54 66 46 Q58 54 50 46 Q42 54 34 46 Q26 54 18 46 Q10 52 2 48Z" fill="${c}" stroke="${o}" ${S}/>
        <circle cx="22" cy="60" r="2.5" fill="#451a03" opacity=".5"/><circle cx="60" cy="62" r="2.5" fill="#451a03" opacity=".5"/><circle cx="84" cy="58" r="2" fill="#451a03" opacity=".5"/>` },
    };
  })();

  function shadeHex(hex, amt) {
    let h = String(hex).replace('#', '');
    if (h.length === 3) h = h.split('').map(x => x + x).join('');
    if (!/^[0-9a-f]{6}$/i.test(h)) return null;
    const n = parseInt(h, 16);
    let r = n >> 16, g = (n >> 8) & 255, b = n & 255;
    const f = v => Math.max(0, Math.min(255, Math.round(amt < 0 ? v * (1 + amt) : v + (255 - v) * amt)));
    return '#' + [f(r), f(g), f(b)].map(v => v.toString(16).padStart(2, '0')).join('');
  }
  function presetSvg(name, color, size) {
    const p = PRESETS[name];
    const c = color || p.color;
    const o = shadeHex(c, -0.55) || '#1b1b2f';
    const s = size || 60;
    return {
      svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 100 100">${p.draw(c, o)}</svg>`,
      cx: s / 2, cy: s / 2,
    };
  }

  /* ---------- costumes made from simple shapes ---------- */
  const CAT = [
    { name: 'costume1', assetId: 'bcf454acf82e4504149f7ffe07081dbc', dataFormat: 'svg', cx: 48, cy: 50 },
    { name: 'costume2', assetId: '0fb9be3e8397c983338cb71dc84d0b25', dataFormat: 'svg', cx: 46, cy: 53 },
  ];
  const SOUNDS = {
    pop: { assetId: '83a9787d4cb6f3b7632b4ddfebf74367', dataFormat: 'wav', rate: 48000, sampleCount: 1032 },
    meow: { assetId: '83c36d806dc92327b9e7049a565c6bff', dataFormat: 'wav', rate: 48000, sampleCount: 40682 },
  };
  const COLOR_RE = /^(#[0-9a-f]{3,8}|[a-z]+)$/i;

  function tokenizeArgs(s) {
    const out = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m;
    while ((m = re.exec(s))) out.push(m[1] !== undefined ? { q: m[1] } : m[2] !== undefined ? { q: m[2] } : { w: m[3].replace(/,$/, '') });
    return out;
  }
  function parseLook(val, isStage, n, extra) {
    extra = extra || [];
    let name = null, body = val;
    const eq = val.match(/^([^=<]+?)\s*=\s*(.+)$/);
    if (eq) { name = eq[1].trim(); body = eq[2]; }
    body = body.trim();
    const base = { name, colors: [], nums: [], text: null, n, stage: isStage };
    if (/^svg\b/i.test(body) || body.startsWith('<')) {
      let src = body.replace(/^svg\b/i, '').trim();
      if (extra.length) src += '\n' + extra.map(e => e.text).join('\n');
      return Object.assign(base, { shape: 'svg' }, parseRawSvg(src, n));
    }
    if (/^draw\b/i.test(body)) return Object.assign(base, { shape: 'draw' }, parseDraw(body, extra, n));
    if (/^(image|picture|url)\b/i.test(body)) {
      const t = body.split(/\s+/);
      if (!t[1] || !/^(https?:|data:image\/)/i.test(t[1])) throw lineErr(n, 'Use "image" and then a link straight to a picture, like: image https://example.com/hero.png');
      return Object.assign(base, { shape: 'image', url: t[1], size: t[2] && NUM_RE.test(t[2]) ? Number(t[2]) : null });
    }
    const toks = tokenizeArgs(body);
    if (!toks.length) throw lineErr(n, 'Missing a costume description');
    let shape = (toks[0].w || '').toLowerCase();
    let rest = toks.slice(1);
    if (!toks[0].w || toks[0].w.startsWith('#')) {
      shape = isStage ? 'color' : 'square'; rest = toks; // e.g. "backdrop: #000000"
    }
    const colors = [], nums = []; let text = null;
    for (const t of rest) {
      if (t.q !== undefined) { text = t.q; continue; }
      if (NUM_RE.test(t.w)) nums.push(Number(t.w));
      else if (COLOR_RE.test(t.w)) colors.push(t.w);
      else if (shape === 'text' && text === null) text = t.w;
      else throw lineErr(n, `I don't understand "${t.w}" (colors look like #ff0000 or red)`);
    }
    const look = Object.assign(base, { shape, colors, nums, text });
    if (isStage && !['color', 'colour', 'solid', 'gradient'].includes(shape)) throw lineErr(n, 'Backdrops can be: #color, gradient #top #bottom, draw, svg, or image <link>');
    if (!isStage && !['circle', 'square', 'rect', 'rectangle', 'triangle', 'star', 'text', 'cat', 'cat2'].includes(shape) && !PRESETS[shape]) {
      throw lineErr(n, `Unknown costume "${shape}". Ready-made ones: ${Object.keys(PRESETS).join(', ')}. Or use circle, square, rect, triangle, star, text, cat, draw, svg, or image <link>`);
    }
    return look;
  }
  function parseRawSvg(src, n) {
    let s = src.trim();
    const a = s.search(/<svg[\s>]/i), b = s.toLowerCase().lastIndexOf('</svg>');
    if (a < 0 || b < 0) throw lineErr(n, 'An svg costume needs <svg ...> at the start and </svg> at the end');
    s = s.slice(a, b + 6)
      .replace(/<script[\s\S]*?<\/script>/gi, '')
      .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*')/gi, '');
    const open = s.match(/<svg[^>]*>/i)[0];
    const attr = k => { const m = open.match(new RegExp('\\s' + k + '\\s*=\\s*["\']([^"\']*)["\']', 'i')); return m ? m[1] : null; };
    let w = parseFloat(attr('width')), h = parseFloat(attr('height'));
    const vb = attr('viewBox');
    if ((!w || !h) && vb) { const p = vb.trim().split(/[\s,]+/).map(Number); w = w || p[2]; h = h || p[3]; }
    w = w || 100; h = h || 100;
    let o2 = open;
    if (!/\sxmlns\s*=/.test(o2)) o2 = o2.replace(/<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"');
    if (/xlink:/.test(s) && !/xmlns:xlink/.test(o2)) o2 = o2.replace(/<svg/i, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
    if (!attr('width')) o2 = o2.replace(/<svg/i, `<svg width="${w}"`);
    if (!attr('height')) o2 = o2.replace(/<svg/i, `<svg height="${h}"`);
    if (!vb) o2 = o2.replace(/<svg/i, `<svg viewBox="0 0 ${w} ${h}"`);
    s = s.replace(open, o2);
    if (typeof DOMParser !== 'undefined') {
      const d = new DOMParser().parseFromString(s, 'image/svg+xml');
      if (d.getElementsByTagName('parsererror').length) throw lineErr(n, 'That SVG has a mistake in it (look for a tag that isn\'t closed)');
    }
    return { svg: s, cx: w / 2, cy: h / 2 };
  }

  // "draw" costumes: build a picture from layered shapes, one per line
  function parseDraw(first, extra, n) {
    const hdr = first.split(/\s+/).slice(1).filter(x => NUM_RE.test(x)).map(Number);
    const W = hdr[0] || 100, H = hdr[1] || hdr[0] || 100;
    let cx = W / 2, cy = H / 2, stroke = '';
    const parts = [];
    if (!extra.length) throw lineErr(n, '"draw" needs shape lines right under it, like: circle red 50 50 30');
    const f = v => +Number(v).toFixed(2);
    for (const { text, n: ln } of extra) {
      const toks = tokenizeArgs(text);
      const cmd = toks[0].w.toLowerCase();
      const rest = toks.slice(1);
      const nums = rest.filter(t => t.w !== undefined && NUM_RE.test(t.w)).map(t => Number(t.w));
      const words = rest.filter(t => t.w !== undefined && !NUM_RE.test(t.w)).map(t => t.w);
      const quoted = (rest.find(t => t.q !== undefined) || {}).q;
      const need = (k, usage) => { if (nums.length < k) throw lineErr(ln, `Use: ${usage}`); };
      const col = c => {
        if (!c) throw lineErr(ln, `"${cmd}" needs a color first, like: ${cmd} red ...`);
        if (!COLOR_RE.test(c)) throw lineErr(ln, `"${c}" isn't a color (try red or #ff0000)`);
        return c;
      };
      switch (cmd) {
        case 'outline':
          stroke = (!words[0] || lc(words[0]) === 'none') ? '' : ` stroke="${col(words[0])}" stroke-width="${nums[0] || 3}" stroke-linejoin="round" stroke-linecap="round"`;
          break;
        case 'center': need(2, 'center x y'); cx = nums[0]; cy = nums[1]; break;
        case 'circle': need(3, 'circle color x y radius'); parts.push(`<circle cx="${f(nums[0])}" cy="${f(nums[1])}" r="${f(nums[2])}" fill="${col(words[0])}"${stroke}/>`); break;
        case 'ellipse': case 'oval':
          need(4, 'ellipse color x y width-radius height-radius [tilt]');
          parts.push(`<ellipse cx="${f(nums[0])}" cy="${f(nums[1])}" rx="${f(nums[2])}" ry="${f(nums[3])}"${nums[4] ? ` transform="rotate(${f(nums[4])} ${f(nums[0])} ${f(nums[1])})"` : ''} fill="${col(words[0])}"${stroke}/>`);
          break;
        case 'square': need(3, 'square color x y size [roundness]'); parts.push(`<rect x="${f(nums[0])}" y="${f(nums[1])}" width="${f(nums[2])}" height="${f(nums[2])}" rx="${f(nums[3] || 0)}" fill="${col(words[0])}"${stroke}/>`); break;
        case 'rect': case 'rectangle':
          need(4, 'rect color x y width height [roundness]');
          parts.push(`<rect x="${f(nums[0])}" y="${f(nums[1])}" width="${f(nums[2])}" height="${f(nums[3])}" rx="${f(nums[4] || 0)}" fill="${col(words[0])}"${stroke}/>`);
          break;
        case 'line':
          need(4, 'line color x1 y1 x2 y2 [thickness]');
          parts.push(`<line x1="${f(nums[0])}" y1="${f(nums[1])}" x2="${f(nums[2])}" y2="${f(nums[3])}" stroke="${col(words[0])}" stroke-width="${f(nums[4] || 4)}" stroke-linecap="round"/>`);
          break;
        case 'polygon': case 'poly':
          if (nums.length < 6 || nums.length % 2) throw lineErr(ln, 'Use: polygon color x1 y1 x2 y2 x3 y3 ... (pairs of numbers, at least 3 points)');
          parts.push(`<polygon points="${nums.map(f).join(' ')}" fill="${col(words[0])}"${stroke}/>`);
          break;
        case 'text':
          need(3, 'text color x y size "your words"');
          if (quoted === undefined) throw lineErr(ln, 'Put the words in quotes, like: text white 50 50 20 "Hi!"');
          parts.push(`<text x="${f(nums[0])}" y="${f(nums[1])}" font-size="${f(nums[2])}" font-family="Sans Serif" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="${col(words[0])}"${stroke}>${esc(quoted)}</text>`);
          break;
        case 'preset': case 'stamp': {
          const pname = lc(words[0] || '');
          if (!PRESETS[pname]) throw lineErr(ln, `Use: preset name [color] x y size. Names: ${Object.keys(PRESETS).join(', ')}`);
          need(3, 'preset name [color] x y size');
          const pc = words[1] ? col(words[1]) : PRESETS[pname].color;
          const sz = nums[2];
          parts.push(`<svg x="${f(nums[0] - sz / 2)}" y="${f(nums[1] - sz / 2)}" width="${f(sz)}" height="${f(sz)}" viewBox="0 0 100 100">${PRESETS[pname].draw(pc, shadeHex(pc, -0.55) || '#1b1b2f')}</svg>`);
          break;
        }
      }
    }
    return { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${parts.join('')}</svg>`, cx, cy };
  }

  // "image <link>": download a picture and wrap it so Scratch can use it as a costume
  async function loadImageLook(l) {
    let dataUri = l.url;
    if (!/^data:/i.test(dataUri)) {
      let res;
      try { res = await fetch(l.url); }
      catch (e) { throw lineErr(l.n, "Couldn't download that picture. Some websites block this; try a link from imgur, GitHub, or Wikimedia, or a data: link"); }
      if (!res.ok) throw lineErr(l.n, `Couldn't download that picture (error ${res.status})`);
      const blob = await res.blob();
      if (!/^image\//.test(blob.type)) throw lineErr(l.n, 'That link isn\'t a picture. Use a link that goes straight to a .png, .jpg, .gif, or .svg file');
      dataUri = await new Promise((ok, bad) => { const fr = new FileReader(); fr.onload = () => ok(fr.result); fr.onerror = bad; fr.readAsDataURL(blob); });
    }
    const img = await new Promise((ok, bad) => {
      const im = new Image();
      im.onload = () => ok(im);
      im.onerror = () => bad(lineErr(l.n, "That picture couldn't be opened"));
      im.src = dataUri;
    });
    let w = img.naturalWidth || 100, h = img.naturalHeight || 100;
    let k = 1;
    if (l.stage) k = Math.max(480 / w, 360 / h);
    else if (l.size) k = l.size / Math.max(w, h);
    else if (Math.max(w, h) > 360) k = 360 / Math.max(w, h);
    w = Math.round(w * k); h = Math.round(h * k);
    return {
      svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><image width="${w}" height="${h}" preserveAspectRatio="none" xlink:href="${dataUri}"/></svg>`,
      cx: w / 2, cy: h / 2,
    };
  }

  function lookSvg(l) {
    if (PRESETS[l.shape]) return presetSvg(l.shape, l.colors[0], l.nums[0]);
    if (l.shape === 'svg' || l.shape === 'draw') return { svg: l.svg, cx: l.cx, cy: l.cy };
    if (l.shape === 'image') return l.built;
    const c1 = l.colors[0] || (l.shape === 'gradient' ? '#4c97ff' : l.shape.startsWith('colo') || l.shape === 'solid' ? '#ffffff' : '#4c97ff');
    const stroke = 'stroke="rgba(0,0,0,0.35)" stroke-width="2"';
    const svg = (w, h, inner) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${inner}</svg>`;
    const p = 2;
    switch (l.shape) {
      case 'color': case 'colour': case 'solid':
        return { svg: svg(480, 360, `<rect width="480" height="360" fill="${c1}"/>`), cx: 240, cy: 180 };
      case 'gradient': {
        const c2 = l.colors[1] || '#ffffff';
        return { svg: svg(480, 360, `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs><rect width="480" height="360" fill="url(#g)"/>`), cx: 240, cy: 180 };
      }
      case 'circle': { const d = l.nums[0] || 40, w = d + p * 2; return { svg: svg(w, w, `<circle cx="${w / 2}" cy="${w / 2}" r="${d / 2}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
      case 'square': { const s = l.nums[0] || 40, w = s + p * 2; return { svg: svg(w, w, `<rect x="${p}" y="${p}" width="${s}" height="${s}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 }; }
      case 'rect': case 'rectangle': {
        const rw = l.nums[0] || 80, rh = l.nums[1] || 20, w = rw + p * 2, h = rh + p * 2;
        return { svg: svg(w, h, `<rect x="${p}" y="${p}" width="${rw}" height="${rh}" rx="3" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: h / 2 };
      }
      case 'triangle': { // points right, so it faces the way the sprite moves
        const s = l.nums[0] || 40, w = s + p * 2;
        return { svg: svg(w, w, `<polygon points="${p},${p} ${s + p},${s / 2 + p} ${p},${s + p}" fill="${c1}" ${stroke}/>`), cx: w / 2, cy: w / 2 };
      }
      case 'star': {
        const s = l.nums[0] || 40, w = s + p * 2, R = s / 2, r = R * 0.45, cx = w / 2;
        const pts = [];
        for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r : R; pts.push(`${(cx + rr * Math.cos(a)).toFixed(2)},${(cx + rr * Math.sin(a)).toFixed(2)}`); }
        return { svg: svg(w, w, `<polygon points="${pts.join(' ')}" fill="${c1}" ${stroke}/>`), cx, cy: cx };
      }
      case 'text': {
        const t = l.text || 'Text', f = l.nums[0] || 24;
        const w = Math.ceil(t.length * f * 0.62) + 8, h = Math.ceil(f * 1.4);
        return { svg: svg(w, h, `<text x="${w / 2}" y="${(f * 1.05).toFixed(1)}" font-family="Sans Serif" font-size="${f}" font-weight="bold" text-anchor="middle" fill="${l.colors[0] || '#000000'}">${esc(t)}</text>`), cx: w / 2, cy: h / 2 };
      }
    }
    return null;
  }

  function makeCostume(vm, look, defaultName) {
    if (look.shape === 'cat' || look.shape === 'cat2') {
      const c = CAT[look.shape === 'cat' ? 0 : 1];
      return { name: look.name || defaultName, assetId: c.assetId, dataFormat: 'svg', md5ext: c.assetId + '.svg', cx: c.cx, cy: c.cy, asset: null };
    }
    const { svg, cx, cy } = lookSvg(look);
    const st = vm.runtime.storage;
    const data = new TextEncoder().encode(svg);
    const asset = st.createAsset(st.AssetType.ImageVector, st.DataFormat.SVG, data, null, true);
    // Cache it so a new sprite's JSON can reference it by id
    try {
      if (typeof st.cache === 'function') st.cache(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
      else if (st.builtinHelper && st.builtinHelper._store) st.builtinHelper._store(st.AssetType.ImageVector, st.DataFormat.SVG, data, asset.assetId);
    } catch (e) { /* ignore */ }
    return { name: look.name || defaultName, assetId: asset.assetId, dataFormat: 'svg', md5ext: asset.assetId + '.svg', cx, cy, asset };
  }

  function findSprite(vm, name) {
    return vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name === name) ||
      vm.runtime.targets.find(t => t.isOriginal && !t.isStage && t.sprite && t.sprite.name.toLowerCase() === name.toLowerCase());
  }

  function ctxFromVM(vm, target) {
    const ctx = { procs: new Map(), procArgs: null, vars: new Set(), lists: new Set() };
    const stage = vm.runtime.getTargetForStage();
    for (const t of [stage, target]) {
      if (!t) continue;
      for (const v of Object.values(t.variables || {})) {
        if (v.type === '') ctx.vars.add(v.name);
        if (v.type === 'list') ctx.lists.add(v.name);
      }
    }
    if (target && target.blocks && target.blocks._blocks) {
      for (const b of Object.values(target.blocks._blocks)) {
        if (b.opcode !== 'procedures_prototype' || !b.mutation) continue;
        try {
          const proccode = b.mutation.proccode;
          const ids = JSON.parse(b.mutation.argumentids || '[]');
          const names = JSON.parse(b.mutation.argumentnames || '[]');
          const toks = proccode.split(' ');
          const types = toks.filter(t => /^%[snb]$/.test(t)).map(t => t[1]);
          const sig = toks.map(t => (/^%[snb]$/.test(t) ? '_' : normWord(t))).filter(Boolean).join(' ');
          ctx.procs.set(sig, { proccode, ids, names, types, warp: String(b.mutation.warp) === 'true', sig });
        } catch (e) { /* ignore */ }
      }
    }
    return ctx;
  }

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const yes = v => /^(yes|true|on|1)$/i.test(v.trim());

  const plainId = () => { const c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let r = ''; for (let i = 0; i < 20; i++) r += c[Math.floor(Math.random() * c.length)]; return r; };
  function countRows(nodes) {
    let n = 0;
    for (const nd of nodes) { n += 1; if (nd.sub) n += countRows(nd.sub) + 1; if (nd.sub2) n += countRows(nd.sub2) + 1; }
    return n;
  }

  // Put scripts straight into a sprite's (or the stage's) code in the project,
  // without needing that sprite to be open in the editor.
  function insertScriptsVM(vm, target, scripts, opts, created) {
    const stage = vm.runtime.getTargetForStage();
    const g = {
      opcodes: new Set(),
      varId(name, type) {
        let v = target.lookupVariableByNameAndType(name, type);
        if (!v) {
          const id = plainId();
          const owner = (opts.local && type !== 'broadcast_msg' && !target.isStage) ? target : stage;
          owner.createVariable(id, name, type);
          v = owner.variables[id];
          if (type !== 'broadcast_msg') created.add(name);
        }
        return v.id;
      },
    };
    // Place new scripts to the right of any code already there
    let x = 40, y = 40;
    const existing = (target.blocks._scripts || []).map(id => target.blocks._blocks[id]).filter(Boolean);
    if (existing.length) x = Math.max(...existing.map(b => Number(b.x) || 0)) + 520;
    for (const sc of scripts) {
      let xml = stackXml(sc, g);
      const topId = plainId();
      let first = true;
      xml = xml.replace(/<(block|shadow) type=/g, (m, tag) => {
        if (first) { first = false; return `<${tag} id="${topId}" x="${Math.round(x)}" y="${Math.round(y)}" type=`; }
        return `<${tag} id="${plainId()}" type=`;
      });
      target.blocks.blocklyListen({ type: 'create', blockId: topId, xml: { outerHTML: xml } });
      y += countRows(sc) * 44 + 70;
    }
  }

  async function createBlocks(text, opts) {
    const ed = getEditor();
    if (!ed) throw new Error("Couldn't find the Scratch code area. Open the project in the editor (See inside) and stay on the Code tab.");
    const vm = ed.vm;
    if (!vm) throw new Error("Couldn't reach the Scratch project (VM).");
    const stage = vm.runtime.getTargetForStage();

    // ---- 1. Parse everything first; nothing changes if there's a mistake
    const secs = splitSections(text);
    if (!secs.length) throw new Error('Nothing to build. Type some blocks first.');
    // Extensions: load any listed with "extension: name", then read their blocks
    const em = vm.extensionManager;
    const isLoaded = id => { try { return em.isExtensionLoaded(id); } catch (e) { return false; } };
    const addedExt = [];
    async function loadExt(ref, n) {
      if (!/^(https?:|data:)/i.test(ref) && isLoaded(ref)) return;
      try { await em.loadExtensionURL(ref); }
      catch (e) { throw lineErr(n, `Couldn't load the extension "${ref}"${e && e.message ? ': ' + e.message : ''}`); }
      addedExt.push(ref);
      await sleep(150);
    }
    for (const sec of secs) {
      for (const d of sec.directives) {
        if (d.key !== 'extension' && d.key !== 'extensions') continue;
        for (const name of d.val.split(',').map(x => x.trim()).filter(Boolean)) await loadExt(extensionRef(name), d.n);
      }
    }
    refreshDynamicSpecs(vm);

    const parseAll = () => {
      for (const sec of secs) {
        const target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
        const isStage = sec.kind === 'stage' || (sec.kind === 'current' && target && target.isStage);
        sec.looks = []; sec.sounds = []; sec.props = {};
        for (const d of sec.directives) {
          if (d.key === 'costume' || d.key === 'backdrop') {
            if (isStage && d.key === 'costume') throw lineErr(d.n, 'The stage has backdrops, not costumes. Use "backdrop:"');
            if (!isStage && d.key === 'backdrop') throw lineErr(d.n, '"backdrop:" goes under "stage:"');
            sec.looks.push(parseLook(d.val, isStage, d.n, d.extra));
          } else if (d.key === 'sound') {
            const k = d.val.toLowerCase();
            if (!SOUNDS[k]) throw lineErr(d.n, 'Built-in sounds you can add: pop, meow');
            sec.sounds.push(k);
          } else if (d.key === 'extension' || d.key === 'extensions') {
            // handled before parsing
          } else {
            if (isStage) throw lineErr(d.n, `"${d.key}:" only works for sprites`);
            if (d.key === 'at' || d.key === 'position') {
              const nums = d.val.split(/[\s,]+/).filter(Boolean).map(Number);
              if (nums.length !== 2 || nums.some(isNaN)) throw lineErr(d.n, 'Use "at: x, y", like "at: 0, -120"');
              sec.props.xy = nums;
            } else if (d.key === 'size' || d.key === 'direction') {
              const v = Number(d.val); if (isNaN(v)) throw lineErr(d.n, `"${d.key}:" needs a number`);
              sec.props[d.key] = v;
            } else if (d.key === 'hidden') sec.props.visible = !yes(d.val);
            else if (d.key === 'visible') sec.props.visible = yes(d.val);
            else if (d.key === 'draggable') sec.props.draggable = yes(d.val);
          }
        }
        sec.scripts = parseText(sec.text, ctxFromVM(vm, target));
      }
    };
    // If a line isn't recognized, see if a built-in extension would know it, add it, and try again
    const srcLines = text.replace(/\r/g, '').split('\n');
    for (let attempt = 0; ; attempt++) {
      try { parseAll(); break; } catch (e) {
        if (!(e instanceof ParseError) || !e.line || attempt > 10) throw e;
        const lineText = lc(srcLines[e.line - 1] || '');
        const hit = EXT_HINTS.find(([id, re]) => !isLoaded(id) && re.test(lineText));
        if (!hit) throw e;
        await loadExt(hit[0], e.line);
        refreshDynamicSpecs(vm);
      }
    }

    // Turn on every extension the code uses (pen, music, ...), even if no "extension:" line asked for it
    const CORE = new Set(['motion', 'looks', 'sound', 'event', 'control', 'sensing', 'operator', 'data', 'procedures', 'argument', 'math', 'text', 'colour', 'note', 'matrix']);
    const usedOps = new Set();
    secs.forEach(sec => sec.scripts.forEach(sc => collectOpcodes(sc, usedOps)));
    for (const op of usedOps) {
      const ext = String(op).split('_')[0];
      if (!CORE.has(ext) && !isLoaded(ext)) await loadExt(ext, 1);
    }

    // Download any pictures now, so a bad link stops things before anything changes
    for (const sec of secs) for (const l of sec.looks) if (l.shape === 'image' && !l.built) l.built = await loadImageLook(l);

    // ---- 2. Build
    const created = new Set(), report = [], cleared = new Set();
    let total = 0;
    for (const sec of secs) {
      let target = sec.kind === 'stage' ? stage : sec.kind === 'sprite' ? findSprite(vm, sec.name) : vm.editingTarget;
      let isNew = false;

      if (sec.kind === 'sprite' && !target) {
        const looks = sec.looks.length ? sec.looks : [{ shape: 'cat' }, { shape: 'cat2', name: 'costume2' }];
        const costumes = looks.map((l, i) => makeCostume(vm, l, `costume${i + 1}`));
        const p = sec.props;
        const json = {
          isStage: false, name: sec.name, variables: {}, lists: {}, broadcasts: {}, blocks: {}, comments: {},
          currentCostume: 0,
          // a temporary placeholder (the cat picture); the real costumes are attached right after
          costumes: [{ name: '__pawprint_placeholder__', bitmapResolution: 1, dataFormat: 'svg', assetId: CAT[0].assetId, md5ext: CAT[0].assetId + '.svg', rotationCenterX: CAT[0].cx, rotationCenterY: CAT[0].cy }],
          sounds: sec.sounds.map(k => ({ name: k, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, format: '', rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount, md5ext: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat })),
          volume: 100, visible: p.visible !== false,
          x: p.xy ? p.xy[0] : Math.round(Math.random() * 200 - 100), y: p.xy ? p.xy[1] : Math.round(Math.random() * 160 - 80),
          size: p.size || 100, direction: p.direction || 90, draggable: !!p.draggable, rotationStyle: 'all around',
        };
        const before = new Set(vm.runtime.targets.map(t => t.id));
        await vm.addSprite(JSON.stringify(json));
        target = findSprite(vm, sec.name) || vm.runtime.targets.find(t => t.isOriginal && !t.isStage && !before.has(t.id));
        if (!target) throw lineErr(sec.line, `Scratch didn't create the sprite "${sec.name}"`);
        // Attach each costume with its picture data directly (works on every Scratch version)
        for (const c of costumes) {
          await vm.addCostume(c.md5ext, {
            name: c.name, dataFormat: c.dataFormat, asset: c.asset || undefined, md5: c.md5ext, assetId: c.assetId,
            rotationCenterX: c.cx, rotationCenterY: c.cy, bitmapResolution: 1, skinId: null,
          }, target.id);
        }
        const ph = target.getCostumeIndexByName('__pawprint_placeholder__');
        if (ph >= 0 && target.getCostumes().length > 1) target.deleteCostume(ph);
        target.setCostume(0);
        isNew = true;
        report.push(`made sprite "${target.sprite.name}"`);
      } else {
        if (!target) continue;
        // Add costumes/backdrops/sounds that aren't there yet
        let firstName = null;
        for (let i = 0; i < sec.looks.length; i++) {
          const base = target.isStage ? 'backdrop' : 'costume';
          const c = makeCostume(vm, sec.looks[i], `${base}${target.getCostumes().length + 1}`);
          if (!firstName) firstName = c.name;
          if (target.getCostumeIndexByName(c.name) >= 0) continue;
          await vm.addCostume(c.md5ext, {
            name: c.name, dataFormat: c.dataFormat, asset: c.asset || undefined, md5: c.md5ext, assetId: c.assetId,
            rotationCenterX: c.cx, rotationCenterY: c.cy, bitmapResolution: 1, skinId: null,
          }, target.id);
        }
        if (firstName) { const ix = target.getCostumeIndexByName(firstName); if (ix >= 0) target.setCostume(ix); }
        for (const k of sec.sounds) {
          if (target.sprite.sounds.some(s => s.name === k)) continue;
          await vm.addSound({ name: k, md5: SOUNDS[k].assetId + '.' + SOUNDS[k].dataFormat, assetId: SOUNDS[k].assetId, dataFormat: SOUNDS[k].dataFormat, rate: SOUNDS[k].rate, sampleCount: SOUNDS[k].sampleCount }, target.id);
        }
        const p = sec.props;
        if (p.xy) target.setXY(p.xy[0], p.xy[1]);
        if (p.size !== undefined) target.setSize(p.size);
        if (p.direction !== undefined) target.setDirection(p.direction);
        if (p.visible !== undefined) target.setVisible(p.visible);
        if (p.draggable !== undefined) target.setDraggable(p.draggable);
        if (sec.looks.length || sec.sounds.length || Object.keys(p).length) report.push(`updated ${target.isStage ? 'the stage' : `"${target.sprite.name}"`}`);
      }

      if (sec.scripts.length) {
        if (opts.replace && !isNew && !cleared.has(target.id)) {
          cleared.add(target.id);
          for (const id of (target.blocks._scripts || []).slice()) target.blocks.deleteBlock(id);
        }
        insertScriptsVM(vm, target, sec.scripts, opts, created);
        total += sec.scripts.length;
        report.push(`${sec.scripts.length} script${sec.scripts.length === 1 ? '' : 's'} → ${target.isStage ? 'Stage' : target.sprite.name}`);
      }
    }
    try { vm.runtime.emitProjectChanged(); } catch (e) { /* ignore */ }
    try { vm.emitTargetsUpdate(); } catch (e) { /* ignore */ }
    try { vm.emitWorkspaceUpdate(); } catch (e) { /* ignore */ }
    if (addedExt.length) report.unshift(`added extension${addedExt.length === 1 ? '' : 's'}: ${addedExt.join(', ')}`);
    return { count: total, created: [...created], report };
  }

  /* =====================================================================
   * 6. UI
   * ===================================================================== */
  const PWP_LOGO = 'data:image/png;base64,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';

  const EXAMPLE = `// Pawprint example: Apple Catcher! Arrow keys to move. Catch apples (+1), catch the bee (+5), dodge bombs!
stage:
backdrop: meadow = draw 480 360
  rect #7dd3fc 0 0 480 360
  preset sun 410 60 90
  preset cloud 120 70 110
  preset cloud 285 45 80
  ellipse #4ade80 110 385 270 125
  ellipse #22c55e 390 395 270 130
  preset tree 60 255 90
  preset mushroom 440 300 40

when flag clicked
set [score v] to (0)
show variable [score v]

sprite: Player
costume: player = hero 70
at: 0, -130

when flag clicked
go to x: (0) y: (-130)
set rotation style [left-right v]
point in direction (90)
forever
  if <key (left arrow v) pressed?> then
    point in direction (-90)
    change x by (-8)
  end
  if <key (right arrow v) pressed?> then
    point in direction (90)
    change x by (8)
  end
end

sprite: Apple
costume: apple = apple 40
sound: pop

when flag clicked
set [speed v] to (4)
go to x: (pick random (-200) to (200)) y: (170)
forever
  change y by ((0) - (speed))
  if <touching (Player v)?> then
    change [score v] by (1)
    change [speed v] by (0.3)
    start sound (pop v)
    go to x: (pick random (-200) to (200)) y: (170)
  end
  if <(y position) < (-170)> then
    go to x: (pick random (-200) to (200)) y: (170)
  end
end

sprite: Bomb
costume: bomb = bomb 44

when flag clicked
go to x: (pick random (-200) to (200)) y: (180)
wait (2) seconds
forever
  change y by ((-2) - ((score) / (4)))
  turn cw (4) degrees
  if <touching (Player v)?> then
    broadcast [game over v]
  end
  if <(y position) < (-170)> then
    go to x: (pick random (-200) to (200)) y: (180)
  end
end

sprite: Bee
// This sprite is drawn from scratch with shapes (no ready-made art)
costume: bee = draw 80 66
  outline #1f2937 2
  ellipse #e0f2fe 32 20 13 9 -25
  ellipse #e0f2fe 47 18 11 8 20
  outline #1f2937 3
  ellipse #facc15 40 41 26 18
  outline none
  ellipse #1f2937 32 41 3 16
  ellipse #1f2937 44 41 3 17
  ellipse #ffffff 30 33 6 3 -20
  outline #1f2937 2
  polygon #1f2937 14 38 5 41 14 44
  circle white 57 37 5
  outline none
  circle #1f2937 58.5 37 2.5
  line #1f2937 55 48 61 46 2
sound: pop
hidden: yes

when flag clicked
hide
wait (4) seconds
forever
  go to x: (-220) y: (pick random (-130) to (40))
  point in direction (90)
  show
  repeat until <(x position) > (215)>
    move (3) steps
    change y by (([sin v] of ((timer) * (300))) * (2))
    if <touching (Player v)?> then
      change [score v] by (5)
      start sound (pop v)
      hide
      set x to (220)
    end
  end
  hide
  wait (pick random (4) to (8)) seconds
end

sprite: GameOver
costume: text "GAME OVER" #dc2626 48
hidden: yes
at: 0, 40

when flag clicked
hide

when I receive [game over v]
go to [front v] layer
show
stop [all v]`;

  function initUI() {
    const css = `
      #ttb-toggle{position:fixed;left:72px;bottom:16px;z-index:9999;background:#1279f5;color:#fff;border:none;border-radius:20px;
        padding:8px 14px;font:600 13px/1 "Helvetica Neue",Helvetica,Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.25)}
      #ttb-toggle:hover{background:#0f5fd6}
      #ttb-toggle img{width:18px;height:18px;vertical-align:-4px;margin-right:2px}
      #ttb-panel .ttb-title{display:flex;align-items:center;gap:6px}
      #ttb-panel .ttb-title img{width:20px;height:20px;background:#fff;border-radius:50%;padding:2px}
      #ttb-panel{position:fixed;left:72px;bottom:60px;width:470px;z-index:10000;background:#fff;border:1px solid #d9d9d9;border-radius:10px;
        box-shadow:0 6px 24px rgba(0,0,0,.25);font:13px "Helvetica Neue",Helvetica,Arial,sans-serif;color:#575e75;display:none}
      #ttb-panel header{background:#1279f5;color:#fff;padding:8px 12px;border-radius:10px 10px 0 0;cursor:move;display:flex;justify-content:space-between;align-items:center;font-weight:600}
      #ttb-panel header button{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;line-height:1}
      #ttb-panel .ttb-body{padding:10px 12px}
      #ttb-panel textarea{width:100%;box-sizing:border-box;height:260px;font:12.5px/1.45 Menlo,Consolas,monospace;border:1px solid #ccc;border-radius:6px;padding:8px;resize:vertical;tab-size:2}
      #ttb-panel .ttb-row{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
      #ttb-panel .ttb-btn{border:none;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
      #ttb-panel .ttb-go{background:#4c97ff;color:#fff}
      #ttb-panel .ttb-go:hover{background:#3373cc}
      #ttb-panel .ttb-sec{background:#e9eef2;color:#575e75}
      #ttb-panel label{display:flex;gap:4px;align-items:center;font-size:12px}
      #ttb-status{margin-top:8px;min-height:18px;font-size:12px;white-space:pre-wrap}
      #ttb-status.err{color:#d4352b}#ttb-status.ok{color:#0b8e69}
      #ttb-panel .ttb-hint{font-size:11px;color:#8a8fa3;margin-top:6px}`;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    const toggle = document.createElement('button');
    toggle.id = 'ttb-toggle';
    toggle.innerHTML = `<img src="${PWP_LOGO}" alt=""> PWP`;
    toggle.style.display = 'none';
    document.body.appendChild(toggle);

    const panel = document.createElement('div');
    panel.id = 'ttb-panel';
    panel.innerHTML = `
      <header><span class="ttb-title"><img src="${PWP_LOGO}" alt=""> Pawprint → Blocks</span><button title="Close">×</button></header>
      <div class="ttb-body">
        <textarea spellcheck="false" placeholder="Write Pawprint (PWP) code here…"></textarea>
        <div class="ttb-row">
          <button class="ttb-btn ttb-go">Create blocks</button>
          <button class="ttb-btn ttb-sec" data-a="open" title="Load a .pwp or .txt file from your computer">📂 Open file</button>
          <button class="ttb-btn ttb-sec" data-a="example">Example</button>
          <input type="file" accept=".pwp,.txt,text/plain" style="display:none">
          <button class="ttb-btn ttb-sec" data-a="clear">Clear</button>
          <label title="New variables/lists are made 'For this sprite only'"><input type="checkbox" data-o="local"> Sprite-only vars</label>
          <label title="Delete the code already in each sprite the text lists before adding the new code (so rebuilding doesn't make duplicates)"><input type="checkbox" data-o="replace"> Replace code</label>
        </div>
        <div id="ttb-status"></div>
        <div class="ttb-hint">(round) = number/reporter · [square] = text · [thing v] = dropdown · &lt;pointy&gt; = boolean · close C-blocks with "end". Start a section with "sprite: Name" or "stage:". Ctrl+Enter to build.</div>
      </div>`;
    document.body.appendChild(panel);

    const ta = panel.querySelector('textarea');
    const status = panel.querySelector('#ttb-status');
    const local = panel.querySelector('[data-o=local]');
    const replace = panel.querySelector('[data-o=replace]');
    try {
      ta.value = localStorage.getItem('ttb-text') || '';
      local.checked = localStorage.getItem('ttb-local') === '1';
      replace.checked = localStorage.getItem('ttb-replace') === '1';
    } catch (e) { /* ignore */ }

    const setStatus = (msg, cls) => { status.textContent = msg; status.className = cls || ''; };
    const save = () => { try { localStorage.setItem('ttb-text', ta.value); localStorage.setItem('ttb-local', local.checked ? '1' : '0'); localStorage.setItem('ttb-replace', replace.checked ? '1' : '0'); } catch (e) { /* ignore */ } };

    async function go() {
      save();
      setStatus('Building…');
      try {
        const { count, created, report } = await createBlocks(ta.value, { local: local.checked, replace: replace.checked });
        let msg = `✓ Done: ${report.length ? report.join(' · ') : `added ${count} script${count === 1 ? '' : 's'}`}.`;
        if (created.length) msg += `\nNew variables/lists made: ${[...new Set(created)].join(', ')} (if one is a typo of a block name, fix the text and try again).`;
        setStatus(msg, 'ok');
      } catch (e) {
        setStatus(e.message || String(e), 'err');
        if (e.line) selectLine(e.line);
        if (!(e instanceof ParseError)) console.error('[Pawprint]', e);
      }
    }
    function selectLine(n) {
      const lines = ta.value.split('\n');
      let start = 0;
      for (let k = 0; k < n - 1; k++) start += lines[k].length + 1;
      ta.focus();
      ta.setSelectionRange(start, start + (lines[n - 1] || '').length);
    }

    toggle.onclick = () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; if (panel.style.display === 'block') ta.focus(); };
    panel.querySelector('header button').onclick = () => { panel.style.display = 'none'; };
    panel.querySelector('.ttb-go').onclick = go;
    // Open a .pwp/.txt file from the computer (button, or drag a file onto the text box)
    const fileInput = panel.querySelector('input[type=file]');
    const loadFile = file => {
      if (!file) return;
      const fr = new FileReader();
      fr.onload = () => { ta.value = String(fr.result).replace(/\r\n/g, '\n'); save(); setStatus(`Loaded "${file.name}" (${ta.value.split('\n').length} lines). Click Create blocks!`, 'ok'); };
      fr.onerror = () => setStatus("Couldn't read that file.", 'err');
      fr.readAsText(file);
    };
    panel.querySelector('[data-a=open]').onclick = () => { fileInput.value = ''; fileInput.click(); };
    fileInput.onchange = () => loadFile(fileInput.files[0]);
    ta.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); });
    ta.addEventListener('drop', e => {
      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) { e.preventDefault(); e.stopPropagation(); loadFile(e.dataTransfer.files[0]); }
    });
    panel.querySelector('[data-a=example]').onclick = () => { ta.value = EXAMPLE; save(); };
    panel.querySelector('[data-a=clear]').onclick = () => { ta.value = ''; save(); setStatus(''); ta.focus(); };
    local.onchange = save;
    replace.onchange = save;
    ta.addEventListener('input', save);
    ta.addEventListener('keydown', e => {
      e.stopPropagation();
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); go(); }
      if (e.key === 'Tab') {
        e.preventDefault();
        const s = ta.selectionStart;
        ta.setRangeText('  ', s, ta.selectionEnd, 'end');
      }
    });
    ['keyup', 'keypress'].forEach(t => ta.addEventListener(t, e => e.stopPropagation()));

    // Dragging the panel by its header
    const header = panel.querySelector('header');
    header.addEventListener('mousedown', e => {
      if (e.target.tagName === 'BUTTON') return;
      const r = panel.getBoundingClientRect();
      const dx = e.clientX - r.left, dy = e.clientY - r.top;
      const move = ev => {
        panel.style.left = Math.max(0, ev.clientX - dx) + 'px';
        panel.style.top = Math.max(0, ev.clientY - dy) + 'px';
        panel.style.bottom = 'auto';
      };
      const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
      e.preventDefault();
    });

    // Only show the button while the editor's code area is on screen
    setInterval(() => {
      const inEditor = !!document.querySelector('[class*="blocks_blocks"], .injectionDiv');
      toggle.style.display = inEditor ? 'block' : 'none';
      if (!inEditor) panel.style.display = 'none';
    }, 1000);
  }

  if (typeof window !== 'undefined' && window.document && !window.__TTB_TEST__) initUI();
  if (typeof window !== 'undefined' && window.__TTB_TEST__) window.__TTB__ = { createBlocks, collectOpcodes, mapValue, parseLook, lookSvg, parseDraw, parseRawSvg, presetSvg, PRESETS, refreshDynamicSpecs, insertScriptsVM, parseText, stackXml, buildCtx, splitSections, parseLook, lookSvg };
})();