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) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-26 16:47:12 -05:00
co-authored by Claude Opus 5
parent 6eaed84fad
commit e46fc706a6
3 changed files with 629 additions and 0 deletions
+336
View File
@@ -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; }
</style>
<div class="wrap">
@@ -714,6 +749,16 @@
saved as <code>bindings.old.txt</code>.
</p>
<div class="loader" id="loader">
<label class="btn" for="bindfile">Load your bindings.txt</label>
<input type="file" id="bindfile" accept=".txt">
<span class="status" id="load-status">…and the keyboard above becomes <b>your</b> keyboard
(drop the file anywhere on this box). Nothing is uploaded — this page reads it locally.</span>
<span class="reset" id="load-reset">show the stock layout again</span>
<span class="loader-legend" id="load-legend"><i>outlined keys</i> differ from the stock board</span>
</div>
<p class="loader-extras" id="load-extras"></p>
<div class="two-col">
<pre><i># grammar</i>
<b>key</b> &lt;KEY&gt; <b>button</b> &lt;addr&gt;
@@ -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 <b>stock</b> 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 <b>" + (fname || "your bindings")
+ "</b> — " + rows + " row(s), <b>" + customCount
+ "</b> key(s) differ from stock.";
resetEl.style.display = "inline";
legendEl.style.display = customCount ? "inline" : "none";
if (extras.length) {
extrasEl.innerHTML = "<b>Also in your file</b> (not on the keyboard): "
+ extras.map(function (l) {
return "<code>" + l.replace(/&/g, "&amp;").replace(/</g, "&lt;")
+ "</code>";
}).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);
});
})();
</script>
+196
View File
@@ -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 <KEYNAME> button <addr> RIO button (hex 0x.. ok)
# key <KEYNAME> keypad <unit> <key> MFD keypad unit 0-2, key 0-15
# key <KEYNAME> axis <channel> deflect <+|-> <rate> spring stick
# key <KEYNAME> axis <channel> slew <+|-> <rate> throttle lever
# key <KEYNAME> axis <channel> set <value> jump/detent
# pad <PADBTN> button <addr>
# padaxis <PADAXIS> axis <channel> [invert] [slew <rate>]
# <KEYNAME> 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 <slot 0-3> [product-name substring]
# joyaxis <X|Y|Z|RX|RY|RZ|SL0|SL1> axis <channel> [invert] [slew <r>] [deadzone <f>]
# joybutton <0-31> button <addr>
# joyhat <0-3> <up|down|left|right> button <addr>
# 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
+97
View File
@@ -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 <pre id="test-out">:
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</script>"
assert closer in frag, "closer not found"
test = frag.replace(closer, harness + closer, 1)
page = ("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<title>test</title>\n</head>\n<body>\n" + test + "\n</body>\n</html>\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")