From e46fc706a6482d5a73c0439012c4d5698719c066 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 26 Jul 2026 16:47:12 -0500 Subject: [PATCH] CONTROLS.html: load your bindings.txt and the board becomes YOUR board The interactive controls page was a static picture of the stock layout -- a player's carried migration rows, rebinds and HOTAS setup were invisible to it, and the mismatch grew with every customization. Now a "Load your bindings.txt" box (file picker + drag-drop) parses the real bindings grammar client-side and rewrites the interactive keyboard in place: * every key shows the loaded file's truth; keys that differ from the stock board get a hazard outline (the legend says so); hover readouts follow because they were already attribute-driven; * the game's bindings-row-wins rule is modelled: the PgUp/PgDn volume and backtick view BUILT-INS keep their face only while their key is unbound; * rows that have no keyboard geometry (pad / padaxis / wizard joyaxis- joybutton-joyhat / MOUSE keys) land in an "also in your file" list; * one click restores the stock view. Nothing is uploaded -- FileReader on a user-chosen local file, works from the extracted zip on file://. Key-name registry walks the three key blocks in DOM order (Shift/Ctrl/Alt resolve left-then-right; the numpad's U+2212 minus and its ASCII twin both map to NUMPADMINUS), address meanings are harvested from the stock board itself plus a small table for the slots the default board leaves off (unwired columns, the cabinet-only throttle bank, PANIC). Two self-inflicted bugs found by the harness and fixed before landing: the address harvest invented a "BTN" label for the many stock keys that have none, which made EVERY default row read as a customization; and generated axis labels ("AIM +") differed from the page's hand-authored vocabulary ("AIM UP"), custom- marking the whole stock numpad. Both now speak the board's own labels. VERIFIED headlessly (no browser extension needed): scratchpad/ test_controls_page.py injects a self-test harness into a copy of the page, feeds it scratchpad/fixture_migrated_bindings.txt -- a REAL board-2 migrated file (full defaults + two authored rows + a wizard section) -- and runs it under chrome --headless=new --dump-dom. 11/11: the two authored rows carried and marked, zero stray custom marks across the rest of the board, both built-ins survive, wizard rows listed, status line honest, and reset restores the stock board exactly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CONTROLS.html | 336 +++++++++++++++++++++++ scratchpad/fixture_migrated_bindings.txt | 196 +++++++++++++ scratchpad/test_controls_page.py | 97 +++++++ 3 files changed, 629 insertions(+) create mode 100644 scratchpad/fixture_migrated_bindings.txt create mode 100644 scratchpad/test_controls_page.py diff --git a/docs/CONTROLS.html b/docs/CONTROLS.html index d0044cb..d22d10c 100644 --- a/docs/CONTROLS.html +++ b/docs/CONTROLS.html @@ -398,6 +398,41 @@ } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + /* ---- "load your bindings.txt" (the board becomes YOUR board) ---- */ + .loader { + margin: 14px 0 4px; + padding: 12px 14px; + border: 1px dashed var(--glass-edge); + border-radius: 6px; + background: var(--void-2); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + } + .loader.dragover { border-color: var(--phosphor); background: var(--glass); } + .loader label.btn { + cursor: pointer; + padding: 6px 14px; + border: 1px solid var(--dim); + border-radius: 4px; + color: var(--phosphor-2); + font-weight: 600; + } + .loader label.btn:hover { border-color: var(--phosphor); } + .loader input[type="file"] { display: none; } + .loader .status { color: var(--dim); font-size: 0.86em; } + .loader .status b { color: var(--phosphor-2); } + .loader .reset { color: var(--dim); font-size: 0.86em; cursor: pointer; text-decoration: underline; display: none; } + .key.custom { + outline: 1px solid var(--hazard-lit); + outline-offset: -1px; + } + .key.custom .k-sub { color: var(--hazard-lit); } + .loader-extras { display: none; margin: 6px 0 0; font-size: 0.86em; color: var(--dim); } + .loader-extras b { color: var(--phosphor-2); } + .loader-legend { display: none; font-size: 0.82em; color: var(--dim); } + .loader-legend i { color: var(--hazard-lit); font-style: normal; }
@@ -714,6 +749,16 @@ saved as bindings.old.txt.

+
+ + + …and the keyboard above becomes your keyboard + (drop the file anywhere on this box). Nothing is uploaded — this page reads it locally. + show the stock layout again + outlined keys differ from the stock board +
+

+
# grammar
 key <KEY>   button <addr>
@@ -1060,5 +1105,296 @@
 
     bind(kbEl, rdKb, ".key");
     bind(document.querySelector("svg.pad"), rdPad, ".ctl");
+    //--------------------------------------------------------------------
+    // LOAD YOUR bindings.txt (2026-07-26): the board above stops being a
+    // picture of the stock layout and becomes the reader's actual keyboard.
+    // Entirely local -- FileReader on a user-chosen file; nothing uploaded.
+    //--------------------------------------------------------------------
+
+    // Registry: bindings.txt KEYNAME -> the rendered key element.  Caps on the
+    // board are display glyphs ("`", "−", "PgUp"), and Shift/Ctrl/Alt appear
+    // twice (left first, right second), so walk each block in DOM order.
+    var keyEls = {};
+    function reg(name, el) { if (name && el && !keyEls[name]) keyEls[name] = el; }
+    (function () {
+      var mainCap = {
+        "`": "BACKTICK", "-": "MINUS", "=": "EQUALS", "[": "LBRACKET",
+        "]": "RBRACKET", "\\": "BACKSLASH", ";": "SEMICOLON", "'": "QUOTE",
+        ",": "COMMA", ".": "PERIOD", "/": "SLASH", "Space": "SPACE",
+        "Tab": "TAB", "Bksp": "BACKSPACE", "Enter": "ENTER"
+      };
+      var dupes = { "Shift": ["LSHIFT", "RSHIFT"], "Ctrl": ["LCTRL", "RCTRL"],
+                    "Alt": ["LALT", "RALT"] };
+      var seen = {};
+      Array.prototype.forEach.call(main.querySelectorAll(".key"), function (el) {
+        var cap = el.firstChild ? el.firstChild.textContent : "";
+        if (!cap) return;
+        if (dupes[cap]) {
+          seen[cap] = (seen[cap] || 0);
+          reg(dupes[cap][Math.min(seen[cap], 1)], el);
+          seen[cap] += 1;
+          return;
+        }
+        if (mainCap[cap]) { reg(mainCap[cap], el); return; }
+        if (/^[A-Z0-9]$/.test(cap) || /^F1?[0-9]$/.test(cap)) reg(cap, el);
+      });
+      var navCap = { "Ins": "INSERT", "Home": "HOME", "PgUp": "PAGEUP",
+                     "Del": "DELETE", "End": "END", "PgDn": "PAGEDOWN",
+                     "↑": "UP", "←": "LEFT", "↓": "DOWN",
+                     "→": "RIGHT" };
+      Array.prototype.forEach.call(navWrap.querySelectorAll(".key"), function (el) {
+        var cap = el.firstChild ? el.firstChild.textContent : "";
+        if (navCap[cap]) reg(navCap[cap], el);
+      });
+      var padCap = { "/": "NUMPADSLASH", "*": "NUMPADSTAR",
+                     "−": "NUMPADMINUS", "+": "NUMPADPLUS",
+                     ".": "NUMPADDOT" };
+      Array.prototype.forEach.call(pad.querySelectorAll(".key"), function (el) {
+        var cap = el.firstChild ? el.firstChild.textContent : "";
+        if (/^[0-9]$/.test(cap)) reg("NUMPAD" + cap, el);
+        else if (padCap[cap]) reg(padCap[cap], el);
+        // the pad's minus renders as U+2212; accept the ASCII twin too
+        else if (cap === "-") reg("NUMPADMINUS", el);
+      });
+    })();
+
+    // Remember every key's stock face, for reset and for the differs-mark.
+    var stock = {};
+    Object.keys(keyEls).forEach(function (name) {
+      var el = keyEls[name], subEl = el.querySelector(".k-sub");
+      stock[name] = {
+        sub: subEl ? subEl.textContent : "",
+        addr: el.getAttribute("data-addr") || "",
+        desc: el.getAttribute("data-desc") || "",
+        name: el.getAttribute("data-name") || ""
+      };
+    });
+
+    // What an address MEANS: harvested from the stock board itself, plus the
+    // slots the default board leaves off (unwired columns, the cabinet-only
+    // throttle bank) so a custom row still reads as something.
+    var addrInfo = {};
+    Object.keys(stock).forEach(function (name) {
+      var s = stock[name];
+      // Keep the sub EXACTLY as the stock board shows it -- most MFD bank keys
+      // have no short label at all, and inventing one ("BTN") made every
+      // default row read as a customization when a real file was loaded.
+      if (/^0x/i.test(s.addr) && !addrInfo[s.addr.toLowerCase()])
+        addrInfo[s.addr.toLowerCase()] = { sub: s.sub, desc: s.desc };
+    });
+    [["0x16", "—", "Column slot the pod never wired"],
+     ["0x17", "—", "Column slot the pod never wired"],
+     ["0x1e", "—", "Column slot the pod never wired"],
+     ["0x1f", "—", "Column slot the pod never wired"],
+     ["0x38", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
+     ["0x39", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
+     ["0x3a", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
+     ["0x3b", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
+     ["0x3c", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
+     ["0x3d", "PANIC", "The pod's panic button"],
+     ["0x3e", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"]
+    ].forEach(function (a) {
+      if (!addrInfo[a[0]]) addrInfo[a[0]] = { sub: a[1], desc: a[2] };
+    });
+
+    var chanShort = { "THROTTLE": "THR", "JOYSTICKX": "TWIST",
+                      "JOYSTICKY": "AIM", "LEFTPEDAL": "L PED",
+                      "RIGHTPEDAL": "R PED", "TURN": "TURN" };
+
+    // One parsed row -> a display face {sub, addr, desc}.
+    function faceOf(tok) {
+      var verb = (tok[2] || "").toUpperCase();
+      if (verb === "BUTTON") {
+        var addr = (tok[3] || "").toLowerCase();
+        var info = addrInfo[addr] || { sub: "BTN", desc: "RIO button" };
+        return { sub: info.sub, addr: tok[3] || "", desc: info.desc };
+      }
+      if (verb === "KEYPAD")
+        return { sub: "KP " + tok[3] + "/" + tok[4], addr: "keypad",
+                 desc: "MFD keypad unit " + tok[3] + ", key " + tok[4] };
+      if (verb === "AXIS") {
+        var ch = (tok[3] || "").toUpperCase();
+        var short_ = chanShort[ch] || tok[3] || "?";
+        var how = (tok[4] || "").toUpperCase();
+        if (how === "SET")
+          return { sub: (ch === "THROTTLE" && tok[5] === "0")
+                        ? "ALL STOP" : short_ + "=" + tok[5],
+                   addr: "axis", desc: tok[3] + " set to " + tok[5] };
+        // Speak the board's own label vocabulary, or every stock axis row in
+        // a loaded file reads as a "customization" purely by spelling
+        // ("AIM +" vs the hand-authored "AIM UP").
+        var axisLabel = {
+          "JOYSTICKY+": "AIM UP", "JOYSTICKY-": "AIM DN",
+          "JOYSTICKX+": "TWIST", "JOYSTICKX-": "TWIST",
+          "LEFTPEDAL+": "L PED", "LEFTPEDAL-": "L PED",
+          "RIGHTPEDAL+": "R PED", "RIGHTPEDAL-": "R PED",
+          "THROTTLE+": "THR +", "THROTTLE-": "THR −",
+          "TURN+": "TURN", "TURN-": "TURN"
+        };
+        var sign = tok[5] === "-" ? "-" : "+";
+        var lbl = axisLabel[ch + sign]
+                  || (short_ + (sign === "-" ? " −" : " +"));
+        return { sub: lbl, addr: "axis",
+                 desc: tok[3] + " " + (how === "SLEW" ? "slew" : "deflect")
+                       + " " + (tok[5] || "") + " " + (tok[6] || "") };
+      }
+      return null;
+    }
+
+    function setFace(el, face, isCustom) {
+      var subEl = el.querySelector(".k-sub");
+      if (face && face.sub) {
+        if (!subEl) {
+          subEl = document.createElement("span");
+          subEl.className = "k-sub";
+          el.appendChild(subEl);
+        }
+        subEl.textContent = face.sub;
+      } else if (subEl) {
+        subEl.remove();
+      }
+      el.setAttribute("data-name", el.firstChild.textContent);
+      el.setAttribute("data-addr", face ? face.addr : "");
+      el.setAttribute("data-desc", face ? face.desc : "");
+      el.classList.toggle("custom", !!isCustom);
+    }
+
+    var statusEl = document.getElementById("load-status");
+    var resetEl = document.getElementById("load-reset");
+    var legendEl = document.getElementById("load-legend");
+    var extrasEl = document.getElementById("load-extras");
+
+    function resetBoard() {
+      Object.keys(keyEls).forEach(function (name) {
+        var el = keyEls[name], s = stock[name];
+        var subEl = el.querySelector(".k-sub");
+        if (s.sub) {
+          if (!subEl) {
+            subEl = document.createElement("span");
+            subEl.className = "k-sub";
+            el.appendChild(subEl);
+          }
+          subEl.textContent = s.sub;
+        } else if (subEl) {
+          subEl.remove();
+        }
+        if (s.name) el.setAttribute("data-name", s.name);
+        else el.removeAttribute("data-name");
+        el.setAttribute("data-addr", s.addr);
+        el.setAttribute("data-desc", s.desc);
+        el.classList.remove("custom");
+      });
+      statusEl.innerHTML = "Showing the stock layout.";
+      resetEl.style.display = "none";
+      legendEl.style.display = "none";
+      extrasEl.style.display = "none";
+    }
+
+    function applyFile(text, fname) {
+      // Parse every row; group by keyboard / elsewhere.
+      var perKey = {}, extras = [], rows = 0;
+      text.split(/\r?\n/).forEach(function (raw) {
+        var line = raw.replace(/#.*$/, "").trim();
+        if (!line) return;
+        var tok = line.split(/\s+/);
+        var kind = tok[0].toUpperCase();
+        if (kind === "KEY" && tok.length >= 3) {
+          rows += 1;
+          var name = tok[1].toUpperCase();
+          if (/^MOUSE/.test(name)) { extras.push(line); return; }
+          (perKey[name] = perKey[name] || []).push(tok);
+        } else if (kind === "PAD" || kind === "PADAXIS"
+                   || kind === "JOYAXIS" || kind === "JOYBUTTON"
+                   || kind === "JOYHAT") {
+          rows += 1;
+          extras.push(line);
+        }
+      });
+      if (rows === 0) {
+        statusEl.innerHTML = "That file has no binding rows — is it really "
+          + "a bindings.txt?";
+        return;
+      }
+
+      // The board becomes the file: every registered key resets, then rows
+      // land.  Built-ins (backtick view, PgUp/PgDn volume) keep their face
+      // unless the file binds that key -- the game's bindings-row-wins rule.
+      var builtIns = { "BACKTICK": 1, "PAGEUP": 1, "PAGEDOWN": 1 };
+      var customCount = 0;
+      Object.keys(keyEls).forEach(function (name) {
+        var el = keyEls[name];
+        var rowsHere = perKey[name];
+        if (!rowsHere || rowsHere.length === 0) {
+          if (builtIns[name]) {            // built-in survives an unbound key
+            setFace(el, { sub: stock[name].sub, addr: stock[name].addr,
+                          desc: stock[name].desc }, false);
+            return;
+          }
+          var wasBound = !!stock[name].sub;
+          setFace(el, null, false);
+          if (wasBound) el.setAttribute("data-desc", "Unbound in your file "
+            + "(stock: " + stock[name].sub + ")");
+          return;
+        }
+        var face = faceOf(rowsHere[0]);
+        if (!face) { setFace(el, null, false); return; }
+        if (rowsHere.length > 1) {
+          face.desc += "  · also: " + rowsHere.slice(1).map(function (t) {
+            return t.slice(2).join(" ");
+          }).join(" / ");
+        }
+        var isCustom = (face.sub !== stock[name].sub)
+                       || (face.addr !== stock[name].addr);
+        if (isCustom) customCount += 1;
+        setFace(el, face, isCustom);
+      });
+
+      statusEl.innerHTML = "Showing " + (fname || "your bindings")
+        + " — " + rows + " row(s), " + customCount
+        + " key(s) differ from stock.";
+      resetEl.style.display = "inline";
+      legendEl.style.display = customCount ? "inline" : "none";
+      if (extras.length) {
+        extrasEl.innerHTML = "Also in your file (not on the keyboard): "
+          + extras.map(function (l) {
+              return "" + l.replace(/&/g, "&").replace(/";
+            }).join(" · ");
+        extrasEl.style.display = "block";
+      } else {
+        extrasEl.style.display = "none";
+      }
+    }
+
+    var loaderEl = document.getElementById("loader");
+    var fileEl = document.getElementById("bindfile");
+    fileEl.addEventListener("change", function () {
+      var f = fileEl.files && fileEl.files[0];
+      if (!f) return;
+      var r = new FileReader();
+      r.onload = function () { applyFile(String(r.result), f.name); };
+      r.readAsText(f);
+    });
+    resetEl.addEventListener("click", resetBoard);
+    ["dragover", "dragenter"].forEach(function (ev) {
+      loaderEl.addEventListener(ev, function (e) {
+        e.preventDefault();
+        loaderEl.classList.add("dragover");
+      });
+    });
+    ["dragleave", "drop"].forEach(function (ev) {
+      loaderEl.addEventListener(ev, function (e) {
+        e.preventDefault();
+        loaderEl.classList.remove("dragover");
+      });
+    });
+    loaderEl.addEventListener("drop", function (e) {
+      var f = e.dataTransfer && e.dataTransfer.files
+              && e.dataTransfer.files[0];
+      if (!f) return;
+      var r = new FileReader();
+      r.onload = function () { applyFile(String(r.result), f.name); };
+      r.readAsText(f);
+    });
   })();
 
diff --git a/scratchpad/fixture_migrated_bindings.txt b/scratchpad/fixture_migrated_bindings.txt
new file mode 100644
index 0000000..bdcd7cd
--- /dev/null
+++ b/scratchpad/fixture_migrated_bindings.txt
@@ -0,0 +1,196 @@
+# bindings-board 2 -- KEEP THIS LINE.  It marks the keyboard-layout generation;
+# files without it are pre-board layouts and are migrated automatically
+# (your own rows and the joystick wizard section are kept, the obsolete old
+# defaults are replaced, and the previous file is saved as bindings.old.txt).
+#
+# bindings.txt -- PadRIO input bindings (glass cockpit).  Auto-written with
+# defaults on first run; edit freely, delete to regenerate.  Grammar:
+#   key      button             RIO button (hex 0x.. ok)
+#   key      keypad        MFD keypad unit 0-2, key 0-15
+#   key      axis  deflect <+|->    spring stick
+#   key      axis  slew    <+|->    throttle lever
+#   key      axis  set             jump/detent
+#   pad       button 
+#   padaxis  axis  [invert] [slew ]
+#  may be a MOUSE BUTTON: MOUSEMIDDLE, MOUSE4, MOUSE5 (the two
+#   side buttons; MOUSEX1/MOUSEX2 are aliases).  MOUSELEFT and MOUSERIGHT
+#   also work but are how you press cockpit buttons -- bind them and they
+#   fire on every panel click too.  Mouse MOVEMENT is not bindable.
+# Channels: Throttle JoystickX JoystickY LeftPedal RightPedal Turn
+#   (Turn = signed composite: + drives the right pedal, - the left --
+#    made for mapping a gamepad stick to turning)
+# Pad buttons: A B X Y LB RB BACK START LS RS DPAD_*  Axes: LX LY RX RY LT RT
+#
+# LAYOUT: THE KEYBOARD IS THE POD'S BUTTON BOARD.  The letter and number
+# rows are the MFD button banks laid out where they sit on the panel, and
+# flight lives on the numpad so the board stays free.  Every one of the 72
+# pod buttons is also one mouse click away on the cockpit itself
+# (right-click = press-and-hold latch).
+#
+# A key bound here is REMOVED from the authentic 1995 typed-hotkey channel
+# so it cannot double-dispatch -- and this board binds nearly everything,
+# so those hotkeys (5 = MFD1 Quad, z = MFD3 Eng1, t/y/u/i/o = pilot select,
+# +/- = target zoom) are given up by design.  Unbind a key here to get its
+# 1995 meaning back.  Same rule for the built-ins: BACKTICK = view toggle
+# (V is a board button now), and J/K/L cycle the Mfd1/2/3 preset pages ONLY
+# while unbound.
+
+# --- flight: the numpad ------------------------------------------------
+# 8/2/4/6 stick, 7/9 pedals, 5 all-stop detent, 0 main trigger.
+# SHIFT/CTRL walk the throttle lever and it STICKS where you leave it;
+# ALT is the throttle handle's reverse-thrust button.
+key NUMPAD8 axis JoystickY deflect + 2.5
+key NUMPAD2 axis JoystickY deflect - 2.5
+key NUMPAD4 axis JoystickX deflect - 2.5
+key NUMPAD6 axis JoystickX deflect + 2.5
+key NUMPAD7 axis LeftPedal deflect + 2.5
+key NUMPAD9 axis RightPedal deflect + 2.5
+key NUMPAD5 axis Throttle set 0
+key LSHIFT axis Throttle slew + 0.7
+key RSHIFT axis Throttle slew + 0.7
+key LCTRL axis Throttle slew - 0.7
+key RCTRL axis Throttle slew - 0.7
+key LALT button 0x3F
+key RALT button 0x3F
+
+# PgUp/PgDn are the VOLUME keys (built in; they produce no typed character
+# so they can never collide with a hotkey) -- leave them unbound.
+
+# --- the four mappable fire buttons ------------------------------------
+# The mouse's side buttons are free if you want them on the trigger:
+#   key MOUSE4 button 0x40
+#   key MOUSE5 button 0x46
+#   key MOUSEMIDDLE button 0x41
+key SPACE   button 0x40   # main trigger
+key NUMPAD0 button 0x40   # main trigger
+key NUMPAD1 button 0x45   # pinky
+key NUMPAD3 button 0x46   # middle thumb
+key NUMPADDOT button 0x47 # upper thumb
+
+# --- the hat: look around, on the arrows -------------------------------
+key UP    button 0x42   # torso CENTER (the shipped .RES name)
+key DOWN  button 0x41   # look behind
+key LEFT  button 0x44   # look left
+key RIGHT button 0x43   # look right
+
+# --- UPPER MFD banks: number row on top, QWERTY beneath ----------------
+# Heat / coolant (0x28-0x2F) -- 1-4 and QWER ARE the coolant system:
+#   1/2/3 Condenser 1-3 valve, 4 coolant FLUSH (hold), Q/W/E Condenser
+#   4-6 valve, R balance coolant.  The valve detents run 1-5-50-CLOSED.
+key 1 button 0x2F
+key 2 button 0x2E
+key 3 button 0x2D
+key 4 button 0x2C
+# (yours wins) key Q button 0x2B
+key W button 0x2A
+key E button 0x29
+key R button 0x28
+# Engineering / Mfd2 (0x20-0x27) -- page-gated: Quad page picks an Eng
+# page, Eng page drives generator select A-D / bus mode / coolant.
+key 5 button 0x27
+key 6 button 0x26
+key 7 button 0x25
+key 8 button 0x24
+# (yours wins) key T button 0x23
+key Y button 0x22
+key U button 0x21
+key I button 0x20
+# Comm / target hotbox (0x30-0x37) -- pilot select.
+key 9 button 0x37
+key 0 button 0x36
+key MINUS  button 0x35
+key EQUALS button 0x34
+key O button 0x33
+key P button 0x32
+key LBRACKET button 0x31
+key RBRACKET button 0x30
+
+# --- LOWER MFD banks: home row on top, the row below beneath -----------
+# G and B are the PANEL GAP between the two lower clusters -- left
+# unbound on purpose, so they keep their 1995 typed meaning.
+# Left Weapons / Mfd1 (0x08-0x0F).
+key A button 0x0F
+key S button 0x0E
+key D button 0x0D
+key F button 0x0C
+key Z button 0x0B
+key X button 0x0A
+key C button 0x09
+key V button 0x08
+# Right Weapons / Mfd3 (0x00-0x07).
+key H button 0x07
+key J button 0x06
+key K button 0x05
+key L button 0x04
+key N button 0x03
+key M button 0x02
+key COMMA  button 0x01
+key PERIOD button 0x00
+
+# --- the two columns flanking the map, top to bottom -------------------
+# Left (0x10-0x15): map zoom in/out, thermal IR, CROUCH, searchlight,
+# cycle secondary display.  Right (0x18-0x1D): cycle control mode
+# (BAS/MID/ADV), unused, Generator A-D on/off.
+key F1 button 0x10
+key F2 button 0x11
+key F3 button 0x12
+key F4 button 0x13
+key F5 button 0x14
+key F6 button 0x15
+key F7 button 0x18
+key F8 button 0x19
+key F9 button 0x1A
+key F10 button 0x1B
+key F11 button 0x1C
+key F12 button 0x1D
+
+# --- XInput pad ---
+# POD-FAITHFUL (default): stick X = torso twist, triggers = pedals.
+# TWIN-STICK alternative: replace the LX line with the two below --
+#   padaxis LX axis Turn
+#   padaxis RX axis JoystickX
+padaxis LX axis JoystickX
+padaxis LY axis JoystickY invert
+padaxis LT axis LeftPedal
+padaxis RT axis RightPedal
+padaxis RY axis Throttle slew 0.7
+pad A button 0x40
+pad B button 0x3F
+pad X button 0x47
+pad Y button 0x42
+pad LB button 0x44
+pad RB button 0x43
+pad DPAD_UP button 0x42
+pad DPAD_DOWN button 0x41
+pad DPAD_LEFT button 0x44
+pad DPAD_RIGHT button 0x43
+
+# --- generic joysticks (flight sticks / HOTAS / pedals; DirectInput) ---
+# EASIEST: run the game once with BT_JOYCONFIG=1 (joyconfig.bat) -- an
+# interactive wizard detects your controls and writes this section for you.
+# Manual grammar (rows attach to the last joydev slot; slot 0 if none):
+#   joydev   [product-name substring]
+#   joyaxis  axis  [invert] [slew ] [deadzone ]
+#   joybutton <0-31> button 
+#   joyhat  <0-3>  button 
+# Typical flight stick (twist = RZ, throttle wheel = SL0):
+#   joydev 0
+#   joyaxis X axis JoystickX
+#   joyaxis Y axis JoystickY invert
+#   joyaxis RZ axis Turn
+#   joyaxis SL0 axis Throttle invert deadzone 0
+#   joybutton 0 button 0x40
+#   joybutton 1 button 0x46
+#   joyhat 0 up button 0x42
+#   joyhat 0 down button 0x41
+#   joyhat 0 left button 0x44
+#   joyhat 0 right button 0x43
+
+# --- carried over from your previous bindings.txt (these beat the defaults above) ---
+key T button 0x2B   # my custom: T = eng page thing I like
+key Q axis Throttle slew + 0.9
+
+# >>> BT_JOYCONFIG generated -- do not edit between markers
+joyaxis 0 X axis JoystickX
+joybutton 0 1 button 0x40
+# <<< BT_JOYCONFIG end
diff --git a/scratchpad/test_controls_page.py b/scratchpad/test_controls_page.py
new file mode 100644
index 0000000..63442a1
--- /dev/null
+++ b/scratchpad/test_controls_page.py
@@ -0,0 +1,97 @@
+"""Headless test for CONTROLS.html's bindings-loader.
+
+Builds a copy of the page with a self-test harness injected inside its IIFE,
+feeds it the fixture (a REAL board-2 migrated bindings.txt: the full defaults
+plus two player-authored rows and a wizard section), and writes the result to
+%TEMP%\controls_test.html.  Run it under headless Chrome and read the
+PASS/FAIL lines out of the dumped DOM's 
:
+
+    python scratchpad/test_controls_page.py
+    chrome --headless=new --disable-gpu --dump-dom file:///%TEMP%/controls_test.html
+
+Asserts: player-authored rows are carried and marked custom, stock rows stay
+unmarked, the PgUp/PgDn and backtick built-ins survive unbound keys, wizard
+rows land in the extras list, and reset restores the stock board exactly.
+Verified 11/11 on 2026-07-26.
+"""
+import io
+import json
+import os
+import tempfile
+
+SCRATCH = tempfile.gettempdir()
+
+frag = io.open(r"C:\git\bt411\docs\CONTROLS.html", encoding="utf-8").read()
+sample = io.open(r"C:\git\bt411\scratchpad\fixture_migrated_bindings.txt",
+                 encoding="utf-8", errors="replace").read()
+
+harness = r"""
+    // ---------- INJECTED SELF-TEST (test copy only) ----------
+    try {
+      var SAMPLE = __SAMPLE__;
+      applyFile(SAMPLE, "migrated-test");
+      var out = [];
+      function t(label, ok) { out.push((ok ? "PASS" : "FAIL") + " " + label); }
+      function elOf(n) { return keyEls[n]; }
+      function subOf(n) {
+        var s = elOf(n) && elOf(n).querySelector(".k-sub");
+        return s ? s.textContent : "";
+      }
+      t("T carries the custom button (VALVE face from 0x2B)",
+        subOf("T") === "VALVE" && elOf("T").classList.contains("custom"));
+      t("Q carries the tweaked throttle (THR +) and is custom",
+        subOf("Q") === "THR +" && elOf("Q").classList.contains("custom"));
+      t("W matches stock (VALVE, not custom)",
+        subOf("W") === "VALVE" && !elOf("W").classList.contains("custom"));
+      t("PgUp built-in volume survives an unbound key",
+        subOf("PAGEUP") === "VOL +"
+        && !elOf("PAGEUP").classList.contains("custom"));
+      t("backtick view built-in survives", subOf("BACKTICK") === "VIEW");
+      t("extras list shows the wizard rows",
+        document.getElementById("load-extras").style.display === "block"
+        && document.getElementById("load-extras")
+             .textContent.indexOf("joyaxis") >= 0);
+      t("status reports a load",
+        document.getElementById("load-status")
+          .textContent.indexOf("differ from stock") >= 0);
+      var stray = 0, strayList = [];
+      Object.keys(keyEls).forEach(function (n) {
+        if (n !== "T" && n !== "Q"
+            && keyEls[n].classList.contains("custom")) {
+          stray += 1;
+          var sEl = keyEls[n].querySelector(".k-sub");
+          strayList.push(n + "[sub='" + (sEl ? sEl.textContent : "")
+            + "' stockSub='" + stock[n].sub + "' addr='"
+            + keyEls[n].getAttribute("data-addr") + "' stockAddr='"
+            + stock[n].addr + "']");
+        }
+      });
+      t("no stray custom marks (only T and Q); strays: "
+        + strayList.join(" "), stray === 0);
+      resetBoard();
+      t("reset restores W", subOf("W") === "VALVE");
+      t("reset restores Q to stock VALVE", subOf("Q") === "VALVE");
+      t("reset clears custom marks",
+        document.querySelectorAll(".key.custom").length === 0);
+      var pre = document.createElement("pre");
+      pre.id = "test-out";
+      pre.textContent = out.join("\n");
+      document.body.appendChild(pre);
+    } catch (err) {
+      var pre2 = document.createElement("pre");
+      pre2.id = "test-out";
+      pre2.textContent = "HARNESS ERROR: " + err + " :: " + (err && err.stack);
+      document.body.appendChild(pre2);
+    }
+"""
+
+harness = harness.replace("__SAMPLE__", json.dumps(sample))
+closer = "  })();\n"
+assert closer in frag, "closer not found"
+test = frag.replace(closer, harness + closer, 1)
+
+page = ("\n\n\n\n"
+        "test\n\n\n" + test + "\n\n\n")
+out = os.path.join(SCRATCH, "controls_test.html")
+io.open(out, "w", encoding="utf-8").write(page)
+print("test page written:", len(page), "chars")