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; }
bindings.old.txt.
+ # 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")