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>