<!-- RUBY-BACKUP · part 145 -->
(!b) return;
    b.addEventListener('touchstart', function(e){ e.preventDefault(); dpadStart(dx,dz); }, {passive:false});
    b.addEventListener('touchend', function(e){ e.preventDefault(); dpadStop(); }, {passive:false});
    b.addEventListener('mousedown', function(){ dpadStart(dx,dz); });
    b.addEventListener('mouseup', function(){ dpadStop(); });
    b.addEventListener('mouseleave', function(){ dpadStop(); });
  }
  bindDpad('dpad-up', 0,-1); bindDpad('dpad-down', 0,1); bindDpad('dpad-left', -1,0); bindDpad('dpad-right', 1,0);
});
try{ var _cc = JSON.parse(localStorage.getItem('ruby-cursor-v1')||'null'); if(_cc && isFinite(_cc.x) && isFinite(_cc.z)) cursor = _cc; }catch(e){}
function saveCursor(){ try{ localStorage.setItem('ruby-cursor-v1', JSON.stringify(cursor)); }catch(e){} }
var cursorDot = new T.Mesh(new T.SphereGeometry(0.12, 10, 8), new T.MeshBasicMaterial({color:0xffe27a}));
cursorDot.visible = false; scene.add(cursorDot);
function paintCursor(){ var cy = (typeof groundY==='function') ? groundY(cursor.x, cursor.z)+0.15 : 0.15; cursorDot.position.set(cursor.x, cy, cursor.z); }
paintCursor();

/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   MARK: GRAMMAR — the tight language
   Atoms:
     <code> [size] <x> <z> [y]   absolute. size defaults to 1 (the seed).
                                 y = height in shells above ground. e.g. c 4 10 10 3
     <code>[size] <n><dir>      relative from cursor. dir: r/l/u/d.      e.g. b2r, l8 2r
     <code>[size]^              stack on the stone at cursor.            e.g. b^
     <code>                     seed of that shape at cursor.             e.g. b
     x <x> <z>  |  x            remove at cell / at cursor.
     oops                        bring back the last project rendered obsolete.
     oops-my-bad                 arm the anchor: tap one stone, every stone connected
                                 to it is offered — `swap <code>` restyles the caps,
                                 `cut` removes, `keep` releases.
     save <name> / saves         name the selection — it becomes a word. `saves`
                                 lists your library; punch a name to build it anywhere.
     newmap                      a fresh field. old saves travel with you;
                                 `oops` brings the old field back.
     send <name> / take <code>    send your build to your friends — the code
                                 IS the build. they `take` it, it joins their library.
     did                         no audio — just "this is what you did": 1. 2. 3.,
                                 the progress, staying visible. tap a row, tap an
                                 anchor stone — the code runs right there.
     lock / unlock / anchors      lock in to new anchor points — tap stones, gold
                                 rings mark them; `anchors` counts them.
     portal <id> / unportal       an anchor of a block as a portal to a build site —
                                 tap the block, tap it again and you meet me there.
   Repeat:     3(b2r)            run the inner line N times.
   Procedure:  tower, wall...    (MARK: PROCEDURES)
   Flip:       flip <proc>       mirrored expansion. (MARK: MIRROR)
   Multi:      split on ';'.     e.g. l 8 10 10; c 4 0 10
   Stamps: procedures build AT the cursor but leave it where it stood —
           atoms move the cursor, procedures do not. castle; castle steps over.
   EDIT: token shapes, new verbs, new directions.
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
var DIRS = {r:[1,0], l:[-1,0], u:[0,-1], d:[0,1]};   /* 1 block = 1 grid cell = 2 units */
var BLOCK = 2;

function parseLine(line, ec){
  var ops = [], parts = String(line).split(';');
  for(var pi=0; pi<parts.length; pi++){
    var p = parts[pi].trim();
    if(!p) continue;
    var sub = parseOne(p, 0, ec);
    if(!sub.ok) return sub;
    ops = ops.concat(sub.ops);
  }
  return {ok:true, ops:ops};
}
function parseOne(p, depth, ec){
  var m = p.match(/^(\d+)\s*\(([^()]*)\)$/);          /* repeat: 3(...) */
  if(m){
    var n = parseInt(m[1],10), inner = m[2].trim();
    if(!(n>=1 && n<=HARDEN.MAX_REPEAT)) return {ok:false, error:'repeat out of range'};
    if(depth+1 > HARDEN.MAX_DEPTH) return {ok:false, error:'too deep'};
    var ops = [];
    for(var i=0;i<n;i++){
      var r = parseOne(inner, depth+1, ec);
      if(!r.ok) return r;
      ops = ops.concat(r.ops);
    }
    return {ok:true, ops:ops};
  }
  var f = p.match(/^flip\s+([a-zA-Z][a-zA-Z0-9_-]*)$/);  /* flip: mirror a procedure */
  if(f){
    var cx0 = ec.x;
    var e = expandProc(f[1], ec, depth);
    if(!e.ok) return e;
    return {ok:true, ops:mirrorOps(e.ops, cx0)};
  }
  if(PROCEDURES[p]) return expandProc(p, ec, depth);  /* procedure word */
  if(SAVES[p]) return {ok:true, ops:saveOps(p, ec)};  /* a saved build, your word */
  return parseAtom(p, ec);                            /* atom */
}
function parseAtom