From d37d76392cd115ef28ec19404a4249ca1e4b86af Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 4 Jul 2026 23:07:52 -0400 Subject: [PATCH 01/17] Add seat-select navigator overlay view (versus / co-op / solo) New sibling overlay view built on the controller-core slot lifecycle: mode select -> per-controller join -> ready gate -> CHOOSE LEVEL, with DualSense lightbar + rumble feedback per seat/team. Becomes Tandemonium's real lobby; reaches the game via sync-controller-core. - apps/overlay/src/lobby.html, src/js/lobby-app.js -- the view - electron/main.js: --lobby flag -> lobby.html (LOBBY_WINDOW_SIZE, stateKey 'lobby') - package.json: start:lobby / prestart:lobby scripts WebHID hardening (Puck lizard-mode churn, DualSense BT handshake) tracked separately in packages/core. Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- apps/overlay/electron/main.js | 5 +- apps/overlay/package.json | 2 + apps/overlay/src/js/lobby-app.js | 304 +++++++++++++++++++++++++++++++ apps/overlay/src/lobby.html | 185 +++++++++++++++++++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 apps/overlay/src/js/lobby-app.js create mode 100644 apps/overlay/src/lobby.html diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index b6f512c..51cf6df 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -25,6 +25,7 @@ let clickThrough = false; // default for the single-controller window is 720×540 — a clean capture size. const DEFAULT_WINDOW_SIZE = { width: 720, height: 540 }; const MULTI_WINDOW_SIZE = { width: 1100, height: 520 }; +const LOBBY_WINDOW_SIZE = { width: 820, height: 660 }; let windowStatePath = null; // resolved once app is ready (needs userData path) let saveWindowSizeTimer = null; @@ -139,6 +140,8 @@ function openInventoryWindow() { // Which size profile this launch uses. Single source of truth so the window // creation and the "reset to default" handler agree on key + default size. function getSizeMode() { + const useLobby = process.env.OVERLAY_LOBBY === '1' || process.argv.includes('--lobby'); + if (useLobby) return { stateKey: 'lobby', defaultSize: LOBBY_WINDOW_SIZE }; const useMulti = process.env.OVERLAY_MULTI === '1' || process.argv.includes('--multi'); return { stateKey: useMulti ? 'multi' : 'main', @@ -173,7 +176,7 @@ function createWindow() { }, }); - const entry = stateKey === 'multi' ? 'multi.html' : 'index.html'; + const entry = stateKey === 'lobby' ? 'lobby.html' : stateKey === 'multi' ? 'multi.html' : 'index.html'; mainWindow.loadFile(path.join(__dirname, '..', 'src', entry)); mainWindow.once('ready-to-show', () => { diff --git a/apps/overlay/package.json b/apps/overlay/package.json index d75abd3..26011be 100644 --- a/apps/overlay/package.json +++ b/apps/overlay/package.json @@ -6,10 +6,12 @@ "scripts": { "prestart": "node scripts/copy-three.js && node scripts/copy-workspace.js", "prestart:multi": "node scripts/copy-three.js && node scripts/copy-workspace.js", + "prestart:lobby": "node scripts/copy-three.js && node scripts/copy-workspace.js", "prepackage": "node scripts/copy-three.js && node scripts/copy-workspace.js", "premake": "node scripts/copy-three.js && node scripts/copy-workspace.js", "start": "electron .", "start:multi": "electron . --multi", + "start:lobby": "electron . --lobby", "make": "electron-forge make", "make:dmg": "npm run package && bash scripts/make-dmg.sh", "package": "electron-forge package" diff --git a/apps/overlay/src/js/lobby-app.js b/apps/overlay/src/js/lobby-app.js new file mode 100644 index 0000000..b43daa5 --- /dev/null +++ b/apps/overlay/src/js/lobby-app.js @@ -0,0 +1,304 @@ +// ============================================================ +// LOBBY-APP.JS — Seat-select navigator (versus / co-op / solo) +// ============================================================ +// +// A controller-driven "player select" built on the controller-core +// ControllerManager. Uses the manager's native slot lifecycle: +// - press any button → manager claims a slot → we seat the player +// - hold PS/Home 2s → manager releases slot → we free the seat +// - B on a joined seat → releaseSlotToPool → free the seat +// On top of that we add mode selection, team/seat topology, a join→ready +// gate, and per-controller lightbar + rumble feedback (DualSense today). +// +// This is the seam that will become Tandemonium's real lobby. Fixing any +// WebHID quirk belongs in packages/core, which flows here (workspace) and +// into the game (sync-controller-core). +// ============================================================ + +import { ControllerManager, ControllerRegistry } from '@usersfirst/controller-core'; + +const SLOT_IDS = ['P1', 'P2', 'P3', 'P4']; +const manager = new ControllerManager({ slotIds: SLOT_IDS }); +window.__manager = manager; + +const $ = (id) => document.getElementById(id); + +// ── seat topology per mode ── +const MODES = ['together', 'versus', 'solo']; +function makeSeats(mode) { + if (mode === 'solo') return { seats: [ + { id: 'cap', role: 'Captain', kind: 'cap', locked: false }, + { id: 'sto', role: 'Stoker', kind: 'sto', locked: true }, + ], fill: ['cap'] }; + if (mode === 'together') return { seats: [ + { id: 'cap', role: 'Captain', kind: 'cap', locked: false }, + { id: 'sto', role: 'Stoker', kind: 'sto', locked: false }, + ], fill: ['cap', 'sto'] }; + return { seats: [ + { id: 'bcap', role: 'Captain', team: 'a' }, { id: 'bsto', role: 'Stoker', team: 'a' }, + { id: 'rcap', role: 'Captain', team: 'b' }, { id: 'rsto', role: 'Stoker', team: 'b' }, + ], fill: ['bcap', 'rcap', 'bsto', 'rsto'] }; +} + +const state = { + screen: 'mode', // mode | lobby | level + mode: null, + seats: [], fill: [], + occ: new Map(), // seatId -> slotId + bySlot: new Map(), // slotId -> { seatId, phase:'joined'|'ready', player } + nextP: 1, +}; + +const seat = (id) => state.seats.find((s) => s.id === id); +const teamCount = (t) => state.seats.filter((s) => s.team === t && state.occ.has(s.id)).length; +function nextOpen() { for (const id of state.fill) { const s = seat(id); if (s && !s.locked && !state.occ.has(id)) return s; } return null; } + +// ── hardware feedback (DualSense lightbar + rumble; others no-op) ── +const RGB = { cap: { r:122, g:224, b:154 }, sto: { r:255, g:158, b:107 }, a: { r:110, g:168, b:255 }, b: { r:255, g:107, b:125 } }; +function seatRGB(seatId) { const s = seat(seatId); if (!s) return RGB.cap; if (s.team) return RGB[s.team]; return s.kind === 'cap' ? RGB.cap : RGB.sto; } +function driverOf(slotId) { const s = manager.getSlot(slotId); return s ? s.driver : null; } +function feedback(slotId, ready) { + const d = driverOf(slotId); const L = state.bySlot.get(slotId); if (!d || !L) return; + const c = seatRGB(L.seatId); const k = ready ? 1 : 0.4; + const lb = { r: (c.r*k)|0, g: (c.g*k)|0, b: (c.b*k)|0 }; + const pat = (d.constructor && d.constructor.PLAYER_LED_PATTERNS && d.constructor.PLAYER_LED_PATTERNS[L.player]) || 0; + try { + if (typeof d.setPlayerFeedback === 'function') d.setPlayerFeedback({ playerLEDs: pat, lightbar: lb }); + else if (typeof d.setLightbar === 'function') d.setLightbar(lb.r, lb.g, lb.b); + } catch (e) {} +} +function clearFb(slotId) { + const d = driverOf(slotId); if (!d) return; + try { + if (typeof d.setPlayerFeedback === 'function') d.setPlayerFeedback({ playerLEDs: 0, lightbar: { r:0, g:0, b:0 } }); + else if (typeof d.setLightbar === 'function') d.setLightbar(0, 0, 0); + } catch (e) {} +} +function rumble(slotId, kind) { + const d = driverOf(slotId); if (!d || typeof d.setRumble !== 'function') return; + try { d.setRumble(0.6, 0.6, 110); if (kind === 'double') setTimeout(() => { try { d.setRumble(0.6, 0.6, 110); } catch (e) {} }, 150); } catch (e) {} +} + +// ── seat transitions ── +function assignSeat(slot) { + const s = nextOpen(); if (!s) return; + state.occ.set(s.id, slot.id); + state.bySlot.set(slot.id, { seatId: s.id, phase: 'joined', player: state.nextP++ }); + feedback(slot.id, false); rumble(slot.id, 'single'); render(); +} +function freeSeat(slotId) { + const L = state.bySlot.get(slotId); if (!L) return; + state.occ.delete(L.seatId); state.bySlot.delete(slotId); clearFb(slotId); render(); +} +function readyUp(slotId) { const L = state.bySlot.get(slotId); if (!L || L.phase !== 'joined') return; L.phase = 'ready'; feedback(slotId, true); rumble(slotId, 'double'); render(); } +function unready(slotId) { const L = state.bySlot.get(slotId); if (!L || L.phase !== 'ready') return; L.phase = 'joined'; feedback(slotId, false); render(); } +function leaveSlot(slotId) { clearFb(slotId); manager.releaseSlotToPool(slotId); } // sync() frees the seat next frame +function switchTeam(slotId, dir) { + const L = state.bySlot.get(slotId); if (!L) return; const s = seat(L.seatId); if (!s || !s.team) return; + const other = s.team === 'a' ? 'b' : 'a'; + const target = state.seats.find((x) => x.team === other && x.role === s.role && !state.occ.has(x.id)) + || state.seats.find((x) => x.team === other && !state.occ.has(x.id)); + if (!target) return; + state.occ.delete(s.id); state.occ.set(target.id, slotId); L.seatId = target.id; + feedback(slotId, L.phase === 'ready'); render(); +} + +function minMet() { + if (state.mode === 'solo') return state.occ.has('cap'); + if (state.mode === 'together') return state.occ.has('cap') && state.occ.has('sto'); + return teamCount('a') >= 1 && teamCount('b') >= 1; +} +function allReady() { const j = [...state.bySlot.keys()]; return j.length > 0 && minMet() && j.every((id) => state.bySlot.get(id).phase === 'ready'); } + +// ── per-slot edge detection ── +const edge = new Map(); +function btns(gp) { + const p = (i) => !!(gp && gp.buttons && gp.buttons[i] && gp.buttons[i].pressed); + const ax = (gp && gp.axes) || []; + return { a: p(0), b: p(1), start: p(9), + up: p(12) || (ax[1] || 0) < -0.5, down: p(13) || (ax[1] || 0) > 0.5, + left: p(14) || (ax[0] || 0) < -0.5, right: p(15) || (ax[0] || 0) > 0.5 }; +} +function edges(slotId, gp) { + const prev = edge.get(slotId) || {}; const cur = btns(gp); const f = {}; + for (const k in cur) f[k] = cur[k] && !prev[k]; + edge.set(slotId, cur); return f; +} + +// ── device naming ── +function devName(slotId) { + const s = manager.getSlot(slotId); + const raw = (s && s.hidDevice && s.hidDevice.productName) || (s && s.controllerLabel) || ''; + const info = ControllerRegistry.identifyFromGamepadId(raw); + return info && info.driverName ? info.driverName : 'Controller'; +} + +// ── screens ── +function showScreen(name) { + state.screen = name; + edge.clear(); // re-prime every slot on the new screen (a button still held from the last screen won't fire) + $('step-mode').hidden = name !== 'mode'; + $('step-lobby').hidden = name !== 'lobby'; + $('step-level').hidden = name !== 'level'; +} + +let modeIdx = 0; +function setModeIdx(i) { + modeIdx = (i + MODES.length) % MODES.length; + document.querySelectorAll('#step-mode [data-mode]').forEach((b, k) => b.classList.toggle('sel', k === modeIdx)); +} +function enterMode(mode) { + state.mode = mode; const m = makeSeats(mode); state.seats = m.seats; state.fill = m.fill; + state.occ.clear(); state.bySlot.clear(); state.nextP = 1; + $('lobby-title').textContent = mode === 'versus' ? 'Build your teams' : mode === 'solo' ? 'Solo ride' : 'Crew your tandem'; + $('join-hint').innerHTML = mode === 'versus' + ? 'Press a button to join · ◀ ▶ switch team · A ready · B leave' + : 'Press a button to claim a seat · A ready · B leave'; + showScreen('lobby'); syncSeats(); render(); +} +function goLevel() { + if (!allReady()) return; + const parts = []; + if (state.mode === 'versus') parts.push(`${teamCount('a')} v ${teamCount('b')}Team Blue vs Team Red`); + else if (state.mode === 'solo') parts.push(`Solo`); + else parts.push(`Co-opCaptain + Stoker`); + const roster = state.seats.filter((s) => state.occ.has(s.id)).map((s) => { + const slotId = state.occ.get(s.id); const L = state.bySlot.get(slotId); + return `${s.role}: P${L ? L.player : '?'} (${devName(slotId)})`; + }).join('
'); + $('level-summary').innerHTML = parts.join('') + '

' + roster; + for (const id of state.bySlot.keys()) clearFb(id); + showScreen('level'); +} + +// ── render ── +function cardHTML(s) { + const kind = s.team ? (s.team === 'a' ? 'ta' : 'tb') : s.kind; + if (s.locked) return `
${s.role}LOCKEDsolo
`; + const slotId = state.occ.get(s.id); + if (slotId) { + const L = state.bySlot.get(slotId); const rdy = L && L.phase === 'ready'; + return `
P${L ? L.player : '?'}${rdy ? '✓ READY' : ''}${s.role}${devName(slotId)}${rdy ? 'ready' : 'press A to ready'}
`; + } + return `
${s.role}OPENpress a button
`; +} +function render() { + const area = $('seat-area'); + if (state.mode === 'versus') { + const col = (t, title) => `
${title}
${state.seats.filter((s) => s.team === t).map(cardHTML).join('')}
`; + area.innerHTML = `
${col('a', 'TEAM BLUE')}
VS
${col('b', 'TEAM RED')}
`; + } else { + area.innerHTML = `
🚲 YOUR TANDEM
${state.seats.map(cardHTML).join('')}
`; + } + updateCTA(); updateDebug(); +} +function updateCTA() { + const btn = $('btn-choose-level'); const r = allReady(); + btn.disabled = !r; btn.classList.toggle('ready', r); + const mu = state.mode === 'versus' && (teamCount('a') || teamCount('b')) ? ` (${teamCount('a')} v ${teamCount('b')})` : ''; + btn.textContent = (r ? '▶ CHOOSE LEVEL' : 'CHOOSE LEVEL') + mu; +} +function updateDebug() { + const el = $('debug'); if (!el) return; + const parts = manager.slots.filter((s) => s.state === 'claimed').map((s) => { + const L = state.bySlot.get(s.id); const d = s.driver; + return `${s.id}:${devName(s.id).split(' ')[0]}${L ? '·' + L.phase : ''}[${d ? (d.connectionType || 'hid') : 'no-hid'}]`; + }); + el.textContent = (parts.join(' ') || 'no controllers — press a button to join') + (allReady() ? ' ✓ ALL READY' : ''); +} + +// ── slot lifecycle sync (claim→seat, release→free) ── +function syncSeats() { + for (const s of manager.slots) if (s.state === 'claimed' && !state.bySlot.has(s.id)) assignSeat(s); + for (const id of [...state.bySlot.keys()]) { const s = manager.getSlot(id); if (!s || s.state !== 'claimed') freeSeat(id); } +} + +// ── per-screen input drivers ── +// Prime-and-skip: a slot with no edge history (just claimed, or first frame on +// a screen) records its current button state and takes no action this frame, +// so the button that CLAIMED the slot can't also trigger select/ready. +function primeOrEdges(slot, gp) { + if (!edge.has(slot.id)) { edge.set(slot.id, btns(gp)); return null; } + return edges(slot.id, gp); +} +function driveMode(pads) { + for (const s of manager.slots) { + if (s.state !== 'claimed') { edge.delete(s.id); continue; } + const e = primeOrEdges(s, s.effectiveGamepad(pads)); if (!e) continue; + if (e.a) { enterMode(MODES[modeIdx]); return; } + if (e.up) setModeIdx(modeIdx - 1); else if (e.down) setModeIdx(modeIdx + 1); + } +} +function driveLobby(pads) { + syncSeats(); + let start = false; + for (const s of manager.slots) { + if (s.state !== 'claimed') { edge.delete(s.id); continue; } + const L = state.bySlot.get(s.id); if (!L) continue; + const e = primeOrEdges(s, s.effectiveGamepad(pads)); if (!e) continue; + if (e.start) start = true; + if (L.phase === 'joined') { + if (e.a) readyUp(s.id); + else if (e.b) leaveSlot(s.id); + else if (e.left) switchTeam(s.id, -1); + else if (e.right) switchTeam(s.id, 1); + } else if (L.phase === 'ready') { + if (e.b) unready(s.id); + } + } + if (start && allReady()) goLevel(); +} + +// ── boot + loop ── +function loop() { + const now = performance.now(); + const pads = (navigator.getGamepads && navigator.getGamepads()) || []; + try { + manager.ingestFrame(pads, now); + if (state.screen === 'mode') driveMode(pads); + else if (state.screen === 'lobby') driveLobby(pads); + updateDebug(); + } catch (e) { + console.error('[lobby] frame error (recovered):', e); + const el = $('debug'); if (el) el.textContent = 'ERROR: ' + (e && e.message ? e.message : String(e)); + } + requestAnimationFrame(loop); +} + +async function boot() { + // Mode buttons: click + controller. + document.querySelectorAll('#step-mode [data-mode]').forEach((b, k) => { + b.addEventListener('click', () => { setModeIdx(k); enterMode(b.dataset.mode); }); + b.addEventListener('mouseenter', () => setModeIdx(k)); + }); + $('btn-back-mode').addEventListener('click', () => { for (const id of state.bySlot.keys()) clearFb(id); showScreen('mode'); }); + $('btn-choose-level').addEventListener('click', goLevel); + $('btn-restart').addEventListener('click', () => enterMode(state.mode)); + $('btn-pair').addEventListener('click', () => { + manager.connectHidForSlot(SLOT_IDS[0]).catch((err) => { const el = $('debug'); if (el) el.textContent = 'pair: ' + err.message; }); + }); + + drawQR($('room-qr'), 'TNDM-7F3K'); + setModeIdx(0); + + if (navigator.hid) { + await manager.autoPoolApprovedHid(); + if (navigator.userAgent.includes('Electron')) { try { await manager.electronAutoRequestDevice(); } catch (e) {} } + manager.wireHidHotplug(); + } + loop(); +} + +// ── deterministic fake QR (online-join affordance) ── +function drawQR(cv, seed) { + if (!cv) return; const ctx = cv.getContext('2d'); const n = 21, cell = cv.width / n; + ctx.fillStyle = '#081109'; ctx.fillRect(0, 0, cv.width, cv.height); + let h = 0x811c9dc5; for (const c of seed) { h ^= c.charCodeAt(0); h = (h * 0x01000193) >>> 0; } + const rnd = () => { h ^= h << 13; h ^= h >>> 17; h ^= h << 5; h >>>= 0; return h / 4294967296; }; + ctx.fillStyle = '#d7ffe6'; + for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (rnd() > 0.5) ctx.fillRect(x*cell, y*cell, cell, cell); + const f = (ox, oy) => { ctx.fillStyle = '#081109'; ctx.fillRect(ox*cell, oy*cell, 7*cell, 7*cell); ctx.fillStyle = '#d7ffe6'; ctx.fillRect((ox+1)*cell, (oy+1)*cell, 5*cell, 5*cell); ctx.fillStyle = '#081109'; ctx.fillRect((ox+2)*cell, (oy+2)*cell, 3*cell, 3*cell); }; + f(0, 0); f(n-7, 0); f(0, n-7); +} + +boot(); diff --git a/apps/overlay/src/lobby.html b/apps/overlay/src/lobby.html new file mode 100644 index 0000000..5f21c1c --- /dev/null +++ b/apps/overlay/src/lobby.html @@ -0,0 +1,185 @@ + + + + + +Tandemonium · Seat-Select Navigator + + + +
+
TANDEMONIUM · SEAT-SELECT NAVIGATOR
+ +
+
+ +
+

How do you want to ride?

+ + + +

Press a button on a controller to navigate · A to choose · or click

+
+ + + + + + +
+
+ +
boot…
+ + + + + + + From 4449ba028362b4a0474efcbb352cfbb317248f74 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 4 Jul 2026 23:23:11 -0400 Subject: [PATCH 02/17] core: elect one lizard-mode owner per Steam Controller Puck The Puck exposes 5 HID interfaces sharing vid:pid, each pooled as its own driver instance. Previously every instance's init() probed all 5 siblings and ran its own 800ms heartbeat -> ~25 SET_REPORT probes on connect and 25 feature-writes per 800ms, most failing NotAllowedError. That churn janked the renderer ~4s and made the DualSense/Steam pads feel unrecognized. Elect a single lizard-mode owner per Puck (keyed by vid:pid): only the first instance probes + heartbeats. The owner already broadcasts CLEAR to all 5 siblings, so suppression coverage is unchanged -- just 5x less work. Ownership is released on destroy() so a re-pool re-elects. Adds owner-election regression tests (exactly-one-timer; re-elect on owner destroy). Full core suite green (95). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- .../src/drivers/steam-controller-driver.js | 27 ++++++++ .../steam-controller-lizard-owner.test.js | 67 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/core/test/steam-controller-lizard-owner.test.js diff --git a/packages/core/src/drivers/steam-controller-driver.js b/packages/core/src/drivers/steam-controller-driver.js index a30c4d0..acc9eb7 100644 --- a/packages/core/src/drivers/steam-controller-driver.js +++ b/packages/core/src/drivers/steam-controller-driver.js @@ -75,6 +75,17 @@ export class SteamControllerDriver extends ControllerDriver { // init(). Static so the setting can be applied without a driver instance. static suppressLizardMode = true; + // One elected lizard-mode owner per physical Puck. All five of the Puck's + // HID interfaces share vid:pid and get pooled as separate driver instances; + // previously EVERY instance probed all five siblings AND ran its own 800ms + // heartbeat — 5× redundant SET_REPORT churn that the NotAllowedError storm + // made expensive enough to jank the renderer (~4s, issue #101). Only the + // first instance to init a given Puck probes + heartbeats; because it already + // broadcasts CLEAR to all five siblings, suppression coverage is unchanged. + // Keyed by vid:pid — two physical Pucks would share one owner, which is fine: + // the owner's heartbeat already targets every matching interface. + static _lizardOwners = new Map(); // 'vid:pid' -> owner driver instance + // Steam Controller emits raw 3-axis gyro (bytes 39-44, ±2000 dps) // and 3-axis accel (bytes 33-38, ±2g) — same rate-based encoding as // DualSense, so it flows through the standard SensorFusion pipeline. @@ -115,6 +126,14 @@ export class SteamControllerDriver extends ControllerDriver { return; } + // Elect a single lizard-mode owner per Puck. Sibling interfaces bail here — + // the owner's heartbeat already broadcasts CLEAR to all of them, so this + // avoids the 5×-per-interface probe + heartbeat churn (issue #101). + const puckKey = this._puckKey(); + const owner = SteamControllerDriver._lizardOwners.get(puckKey); + if (owner && owner !== this && owner._lizardTimer) return; + SteamControllerDriver._lizardOwners.set(puckKey, this); + // Puck path: disable lizard-mode (the firmware's default keyboard + // mouse + scroll emulation) by sending CLEAR_DIGITAL_MAPPINGS + // SET_SETTINGS (trackpads = NONE) to every Puck HID handle, then @@ -251,8 +270,16 @@ export class SteamControllerDriver extends ControllerDriver { this._lizardTimer = null; } this._lizardCandidates = []; + // Release ownership so the next connect for this Puck can re-elect an owner + // (e.g. this interface unplugged/re-pooled) rather than being skipped. + if (SteamControllerDriver._lizardOwners.get(this._puckKey()) === this) { + SteamControllerDriver._lizardOwners.delete(this._puckKey()); + } } + /** Stable per-Puck key. All 5 interfaces of one Puck share vid:pid. */ + _puckKey() { return `${this.device.vendorId}:${this.device.productId}`; } + /** * Enumerate already-approved HID handles for this Puck (primary + * siblings sharing vid:pid). Returns at least [this.device] even if diff --git a/packages/core/test/steam-controller-lizard-owner.test.js b/packages/core/test/steam-controller-lizard-owner.test.js new file mode 100644 index 0000000..81b5a82 --- /dev/null +++ b/packages/core/test/steam-controller-lizard-owner.test.js @@ -0,0 +1,67 @@ +// ============================================================ +// SteamControllerDriver — Puck lizard-mode owner election (#101) +// ============================================================ +// +// The Puck exposes 5 HID interfaces sharing vid:pid; the manager pools each +// as its own driver instance. init() must elect a SINGLE lizard-mode owner +// (probe + 800ms heartbeat). The owner already broadcasts CLEAR to all five +// siblings, so running init on every instance was 5x redundant SET_REPORT +// churn (the NotAllowedError storm that janked the renderer ~4s). These lock +// "exactly one heartbeat timer" and "ownership is released on destroy". + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { SteamControllerDriver } from '../src/drivers/steam-controller-driver.js'; + +const PUCK_PID = 0x1304; +const key = (d) => `${d.vendorId}:${d.productId}`; + +// Minimal Puck HID interface. All instances share vid:pid, as the 5 real +// interfaces do. No navigator.hid in the test env, so _candidatePuckDevices +// resolves to just [this.device] — which is all the election test needs. +function mockPuckDevice() { + return { + vendorId: 0x28de, productId: PUCK_PID, productName: 'Steam Controller Puck', + opened: true, + collections: [{ + usagePage: 0xff00, usage: 0x0001, + featureReports: [{ reportId: 0x01, items: [{ reportSize: 8, reportCount: 64 }] }], + }], + async open() { this.opened = true; }, + async sendFeatureReport() { /* accept the CLEAR / SET_SETTINGS */ }, + }; +} + +test('Puck: exactly one interface owns the lizard-mode heartbeat (no 5x churn)', async () => { + SteamControllerDriver._lizardOwners.clear(); + const drivers = Array.from({ length: 5 }, () => new SteamControllerDriver(mockPuckDevice(), 'usb', null)); + try { + for (const drv of drivers) await drv.init(); + const withTimer = drivers.filter((d) => d._lizardTimer != null); + assert.strictEqual(withTimer.length, 1, 'one heartbeat timer across all 5 interfaces'); + } finally { + for (const drv of drivers) drv.destroy(); + } +}); + +test('Puck: destroying the owner releases ownership so a fresh connect re-elects', async () => { + SteamControllerDriver._lizardOwners.clear(); + const a = new SteamControllerDriver(mockPuckDevice(), 'usb', null); + const b = new SteamControllerDriver(mockPuckDevice(), 'usb', null); + try { + await a.init(); + await b.init(); + assert.ok(a._lizardTimer && !b._lizardTimer, 'a owns, b skips'); + + a.destroy(); + assert.strictEqual(SteamControllerDriver._lizardOwners.get(key(a.device)), undefined, 'ownership released on destroy'); + + const c = new SteamControllerDriver(mockPuckDevice(), 'usb', null); + await c.init(); + assert.ok(c._lizardTimer, 'a fresh instance re-elects as owner after the previous owner was destroyed'); + c.destroy(); + } finally { + b.destroy(); + } +}); From 3eca6bb79d6180bf4acbb39d8bf623e91db80c1f Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sat, 4 Jul 2026 23:43:19 -0400 Subject: [PATCH 03/17] core: harden DualSense/DS4 Bluetooth full-report handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a still-negotiating hot-plug BT link the activating feature read (DS5 0x05 / DS4 0x02) throws, but _activateFullReportMode still waited ~450ms per attempt (5 attempts) for a 0x31/0x11 report that could never arrive — ~2s of dead init that also serialized the pool loads queued behind it. - Skip the wait when the activating read THROWS (link not ready): bail fast to the background loop instead of blocking. Reads that are ACCEPTED still wait, since that's the "device got the command, give it a beat" case. - Trim inline attempts 5->3 and perAttemptMs 450->300; keep the cold-plug fast path (first accepted read + 0x31 within ms). - Background re-activation now nudges immediately (was idling a full interval first) and polls every 500ms (was 1000ms), so gyro recovers sooner once the link settles. Adds handshake tests (thrown-read skips wait / accepted-read activates / background nudges immediately). Core suite green (98). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- .../core/src/drivers/playstation-driver.js | 33 +++++++--- .../test/playstation-bt-handshake.test.js | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 packages/core/test/playstation-bt-handshake.test.js diff --git a/packages/core/src/drivers/playstation-driver.js b/packages/core/src/drivers/playstation-driver.js index d12653a..eee1367 100644 --- a/packages/core/src/drivers/playstation-driver.js +++ b/packages/core/src/drivers/playstation-driver.js @@ -97,7 +97,7 @@ export class PlayStationDriver extends ControllerDriver { * * @returns {Promise} whether the full report stream was confirmed */ - async _activateFullReportMode(featureId, { attempts = 5, perAttemptMs = 450 } = {}) { + async _activateFullReportMode(featureId, { attempts = 3, perAttemptMs = 300 } = {}) { const fullId = this._fullReportId(); const hex = (n) => '0x' + n.toString(16).padStart(2, '0'); // A device without receiveFeatureReport is a test/unsupported handle — @@ -106,23 +106,32 @@ export class PlayStationDriver extends ControllerDriver { const maxAttempts = canRead ? attempts : 1; for (let i = 1; i <= maxAttempts; i++) { + let readOk = true; if (canRead) { try { await this.device.receiveFeatureReport(featureId); } catch (err) { + readOk = false; console.warn(`PlayStation BT: feature ${hex(featureId)} query failed (attempt ${i}/${maxAttempts}):`, err.message); } } - const streaming = await this._waitForReport(fullId, perAttemptMs); - if (streaming) { - console.log(`PlayStation BT: full report mode active (${hex(fullId)}) after ${i} attempt(s)`); - return true; + // Only wait for the stream when the activating read was ACCEPTED. A + // thrown read means the BT link is still negotiating (the hot-plug case) + // — no full report will arrive, so skip the wait and let init hand off to + // the non-blocking background loop instead of blocking ~perAttemptMs per + // doomed attempt (this was ~2s of dead init that serialized pool loads). + if (readOk) { + const streaming = await this._waitForReport(fullId, perAttemptMs); + if (streaming) { + console.log(`PlayStation BT: full report mode active (${hex(fullId)}) after ${i} attempt(s)`); + return true; + } } if (i < maxAttempts) { console.log(`PlayStation BT: still in compatibility mode after feature ${hex(featureId)} (attempt ${i}/${maxAttempts}); retrying`); } } - console.warn(`PlayStation BT: full report mode (${hex(fullId)}) did not start after ${maxAttempts} attempt(s) — gyro unavailable until reconnect`); + console.warn(`PlayStation BT: full report mode (${hex(fullId)}) not up inline after ${maxAttempts} attempt(s) — handing off to background re-activation`); return false; } @@ -164,7 +173,7 @@ export class PlayStationDriver extends ControllerDriver { * * @returns {Promise} true if the stream started, false if it gave up */ - _startBackgroundReactivation(featureId, { intervalMs = 1000, maxMs = 12000 } = {}) { + _startBackgroundReactivation(featureId, { intervalMs = 500, maxMs = 12000 } = {}) { if (this._reactivateTimer) return Promise.resolve(false); const fullId = this._fullReportId(); const hex = (n) => '0x' + n.toString(16).padStart(2, '0'); @@ -179,15 +188,19 @@ export class PlayStationDriver extends ControllerDriver { resolve(ok); }; const onReport = (event) => { if (event.reportId === fullId) finish(true); }; + const nudge = () => { + if (typeof this.device.receiveFeatureReport === 'function') { + this.device.receiveFeatureReport(featureId).catch(() => {}); + } + }; this._reactivateStop = () => finish(false, 'driver destroyed'); this.device.addEventListener('inputreport', onReport); console.warn(`PlayStation BT: full report stream not up yet — re-activating in the background (every ${intervalMs}ms, up to ${maxMs}ms)`); + nudge(); // fire the first read immediately — don't idle a full interval before nudging this._reactivateTimer = setInterval(() => { elapsed += intervalMs; if (elapsed > maxMs) { finish(false, `no full report after ${maxMs}ms`); return; } - if (typeof this.device.receiveFeatureReport === 'function') { - this.device.receiveFeatureReport(featureId).catch(() => {}); - } + nudge(); }, intervalMs); }); } diff --git a/packages/core/test/playstation-bt-handshake.test.js b/packages/core/test/playstation-bt-handshake.test.js new file mode 100644 index 0000000..c54ff79 --- /dev/null +++ b/packages/core/test/playstation-bt-handshake.test.js @@ -0,0 +1,62 @@ +// ============================================================ +// PlayStationDriver — Bluetooth full-report activation handshake (#101) +// ============================================================ +// +// Over BT the DualSense/DS4 start in compatibility mode (report 0x01, no IMU); +// reading a family feature report (DS5 0x05 / DS4 0x02) flips them to the full +// IMU report (0x31 / 0x11). On a still-negotiating hot-plug link that read +// THROWS, and the old code still waited ~450ms per attempt for a report that +// couldn't come — ~2s of dead init that serialized subsequent pool loads. +// These lock the hardened behavior: a thrown read skips the wait and fails +// fast, an accepted read + full report activates, and the background loop +// nudges immediately instead of idling a full interval. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { PlayStationDriver } from '../src/drivers/playstation-driver.js'; + +function mockBtDS({ readRejects = false } = {}) { + const listeners = new Map(); + return { + vendorId: 0x054c, productId: 0x0ce6, opened: true, collections: [], + reads: 0, + addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn); }, + removeEventListener(type, fn) { listeners.get(type)?.delete(fn); }, + async receiveFeatureReport() { this.reads++; if (readRejects) throw new Error('Failed to receive the feature report'); }, + _emit(type, ev) { for (const fn of (listeners.get(type) || [])) fn(ev); }, + }; +} + +test('BT activation: a thrown feature read skips the wait and fails fast', async () => { + const dev = mockBtDS({ readRejects: true }); + let waited = false; + const origAdd = dev.addEventListener.bind(dev); + dev.addEventListener = (type, fn) => { if (type === 'inputreport') waited = true; origAdd(type, fn); }; + + const drv = new PlayStationDriver(dev, 'bluetooth', { mode: 'ds5' }); + // perAttemptMs is huge on purpose — if the wait ran, this test would hang. + const active = await drv._activateFullReportMode(0x05, { attempts: 3, perAttemptMs: 5000 }); + + assert.strictEqual(active, false, 'no full report → not active'); + assert.strictEqual(waited, false, 'a thrown read must not enter the perAttemptMs wait'); + assert.strictEqual(dev.reads, 3, 'still re-issues the activating read each attempt'); +}); + +test('BT activation: accepted read + full report (0x31) → active', async () => { + const dev = mockBtDS({ readRejects: false }); + const drv = new PlayStationDriver(dev, 'bluetooth', { mode: 'ds5' }); + const p = drv._activateFullReportMode(0x05, { attempts: 1, perAttemptMs: 2000 }); + setTimeout(() => dev._emit('inputreport', { reportId: 0x31 }), 15); + assert.strictEqual(await p, true, 'full report during the wait → active'); +}); + +test('BT background re-activation nudges immediately, before the first interval', async () => { + const dev = mockBtDS({ readRejects: false }); + const drv = new PlayStationDriver(dev, 'bluetooth', { mode: 'ds5' }); + const p = drv._startBackgroundReactivation(0x05, { intervalMs: 10000, maxMs: 60000 }); + await new Promise((r) => setTimeout(r, 40)); // « intervalMs: only an immediate nudge could fire + assert.ok(dev.reads >= 1, 'fired an immediate read without idling a full interval'); + dev._emit('inputreport', { reportId: 0x31 }); // flip the stream so the loop finishes + clears its timer + assert.strictEqual(await p, true); +}); From 09e223500e9ca0bd5ab80b7027540c6de6688198 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 5 Jul 2026 00:10:24 -0400 Subject: [PATCH 04/17] core: retire electronAutoRequestDevice (unfixable at boot) to a no-op WebHID requestDevice() requires transient user activation, so the boot-time fire-and-forget call always threw "Must be handling a user gesture to show a permission request" and pooled nothing -- dead code that only logged a scary error. There is no gesture-free way to grant a NOT-yet-approved device at boot, so remove the attempt. Boot pairing now rests on the paths that actually work: - already-approved devices -> autoPoolApprovedHid() (gesture-free) - a new device -> connectHidForSlot() from a user gesture (the overlay's per-slot Connect/Pair button) - hot-plug of approved ones -> wireHidHotplug() The method is kept as a documented no-op so downstream/synced callers (the game's boot chain) don't break; overlay callers (multi-app, lobby-app) drop the call. Regression test locks "no-op, never calls requestDevice". Core suite green (99). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- apps/overlay/src/js/lobby-app.js | 4 +- apps/overlay/src/js/multi-app.js | 6 +-- packages/core/src/manager.js | 33 ++++++---------- .../manager-electron-auto-request.test.js | 39 +++++++++++++++++++ 4 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 packages/core/test/manager-electron-auto-request.test.js diff --git a/apps/overlay/src/js/lobby-app.js b/apps/overlay/src/js/lobby-app.js index b43daa5..34afb3c 100644 --- a/apps/overlay/src/js/lobby-app.js +++ b/apps/overlay/src/js/lobby-app.js @@ -282,8 +282,10 @@ async function boot() { setModeIdx(0); if (navigator.hid) { + // Pool already-approved controllers (gesture-free). New ones pair via the + // per-slot "Pair" button (connectHidForSlot needs a user gesture); hot-plug + // of approved devices is handled by wireHidHotplug. await manager.autoPoolApprovedHid(); - if (navigator.userAgent.includes('Electron')) { try { await manager.electronAutoRequestDevice(); } catch (e) {} } manager.wireHidHotplug(); } loop(); diff --git a/apps/overlay/src/js/multi-app.js b/apps/overlay/src/js/multi-app.js index d4cc8be..2901cec 100644 --- a/apps/overlay/src/js/multi-app.js +++ b/apps/overlay/src/js/multi-app.js @@ -339,10 +339,10 @@ async function boot() { }); if (navigator.hid) { + // Already-approved controllers pool gesture-free; new ones pair via the + // per-slot Connect button (requestDevice needs a user gesture); hot-plug + // of approved devices is handled by wireHidHotplug. await manager.autoPoolApprovedHid(); - if (navigator.userAgent.includes('Electron')) { - await manager.electronAutoRequestDevice(); - } manager.wireHidHotplug(); } diff --git a/packages/core/src/manager.js b/packages/core/src/manager.js index 677bda1..759f429 100644 --- a/packages/core/src/manager.js +++ b/packages/core/src/manager.js @@ -893,28 +893,19 @@ export class ControllerManager { } /** - * Electron-only: requestDevice auto-approved by main.js handler, so we - * can call it at boot to pool controllers that haven't been approved yet. + * @deprecated No-op. Auto-pairing not-yet-approved devices at boot is + * impossible: WebHID `requestDevice()` requires transient user activation, + * which a fire-and-forget boot call doesn't have — it threw "Must be handling + * a user gesture to show a permission request" and pooled nothing. The paths + * that actually work: + * • already-approved devices → autoPoolApprovedHid() (gesture-free) + * • a not-yet-approved device → connectHidForSlot() from a user gesture + * (the overlay's per-slot Connect button) + * • hot-plug of approved ones → wireHidHotplug() + * Kept as a no-op so existing/synced callers (e.g. the game's boot chain) + * don't break; safe to delete once all call sites are gone. */ - async electronAutoRequestDevice() { - if (!navigator.hid) return; - const filters = ControllerRegistry.getHIDFilters(); - await new Promise((r) => setTimeout(r, 400)); - // Call once per slot count so we cover the typical 2-controller case - // without looping forever on a single-controller setup. - for (let i = 0; i < this.slots.length; i++) { - try { - const picked = await navigator.hid.requestDevice({ filters }); - const d = (picked || []).find((dev) => !this._isDeviceInPoolOrSlot(dev)); - if (!d) break; - await this.poolDevice(d); - await new Promise((r) => setTimeout(r, 200)); - } catch (err) { - console.log('electronAutoRequestDevice stopped:', err.message); - break; - } - } - } + async electronAutoRequestDevice() { /* intentionally a no-op — see deprecation note */ } /** * User-gesture HID pairing, initiated from a Connect button. If a slot diff --git a/packages/core/test/manager-electron-auto-request.test.js b/packages/core/test/manager-electron-auto-request.test.js new file mode 100644 index 0000000..4781c6e --- /dev/null +++ b/packages/core/test/manager-electron-auto-request.test.js @@ -0,0 +1,39 @@ +// ============================================================ +// ControllerManager.electronAutoRequestDevice — deprecated no-op (#101) +// ============================================================ +// +// Auto-pairing not-yet-approved devices at boot is impossible: WebHID +// requestDevice() needs transient user activation, so a fire-and-forget boot +// call threw "Must be handling a user gesture" and pooled nothing. The method +// is now a no-op (kept for downstream/synced callers). This locks that it +// neither throws nor calls requestDevice, so the broken boot loop can't creep +// back in. Approved devices pool via autoPoolApprovedHid(); new ones pair via +// connectHidForSlot() from a user gesture. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ControllerManager } from '../src/manager.js'; + +test('electronAutoRequestDevice is a no-op: never calls requestDevice at boot', async () => { + const mgr = new ControllerManager({ slotIds: ['P1', 'P2'] }); + + let requestCalls = 0; + // navigator is a read-only getter in modern Node — override via defineProperty. + const orig = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { hid: { requestDevice: async () => { requestCalls++; return []; } } }, + }); + try { + await assert.doesNotReject( + () => mgr.electronAutoRequestDevice(), + 'must not throw the "requires a user gesture" error at boot', + ); + assert.strictEqual(requestCalls, 0, 'must not call navigator.hid.requestDevice (no boot gesture)'); + assert.strictEqual(mgr._hidPool.size, 0, 'pools nothing'); + } finally { + if (orig) Object.defineProperty(globalThis, 'navigator', orig); + else delete globalThis.navigator; + } +}); From a40a5c0d9adb29c6ccb9cb4f3f180d2befba1825 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 5 Jul 2026 09:31:23 -0400 Subject: [PATCH 05/17] overlay: add root start:lobby passthrough to the overlay workspace So 'npm run start:lobby' works from the repo root, not only from apps/overlay. Delegates to @usersfirst/overlay's start:lobby (which runs its prestart copy-workspace + electron . --lobby). Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 653ad6e..e4cc609 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ ], "scripts": { "test": "npm run test --workspaces --if-present", + "start:lobby": "npm run start:lobby --workspace @usersfirst/overlay", "face-painter": "npx -y serve tools/face-painter", "split-glb": "node tools/split-glb.js", "hid-probe": "npx -y serve tools/hid-probe", From a16c4922b3897a03705f6083709f425afbeefa09 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 5 Jul 2026 09:39:38 -0400 Subject: [PATCH 06/17] overlay(lobby): always-visible Pair button + live controller count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay is a fresh WebHID origin, so grants from the game don't carry over and getDevices() returns nothing until you pair here — and the Pair button only lived on the lobby screen, so an HID-only Steam Controller couldn't get you off the mode screen (chicken-and-egg). Add a persistent top-right 'Pair controller' button (approves the next device; main.js auto-selects) and a live ' active / paired' count visible from the mode screen, so controllers can be paired and seen immediately. DualSense still works via the Gamepad API without pairing; pairing adds its lightbar/gyro and is required for the Steam Controller. Refs #101 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JMP6NyJJiW2KjvSZsZ6XwD --- apps/overlay/src/js/lobby-app.js | 21 ++++++++++++++++++--- apps/overlay/src/lobby.html | 8 ++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/overlay/src/js/lobby-app.js b/apps/overlay/src/js/lobby-app.js index 34afb3c..3095758 100644 --- a/apps/overlay/src/js/lobby-app.js +++ b/apps/overlay/src/js/lobby-app.js @@ -206,6 +206,21 @@ function updateDebug() { }); el.textContent = (parts.join(' ') || 'no controllers — press a button to join') + (allReady() ? ' ✓ ALL READY' : ''); } +function updateControllerCount() { + const el = $('ctrl-count'); if (!el) return; + const claimed = manager.slots.filter((s) => s.state === 'claimed').length; + const pooled = manager._hidPool.size; + el.textContent = (claimed === 0 && pooled === 0) + ? 'no controllers — press a button or Pair →' + : `${claimed} active${pooled ? ` · ${pooled} paired` : ''}`; +} +// Approve/pool the next controller (main.js auto-selects a not-yet-picked one). +// requestDevice needs the click as its user gesture; the Steam Controller is +// HID-only, so it can only be recognized after this. +function pairController() { + const free = SLOT_IDS.find((id) => manager.getSlot(id).state !== 'claimed') || SLOT_IDS[0]; + manager.connectHidForSlot(free).catch((err) => { const el = $('debug'); if (el) el.textContent = 'pair: ' + (err && err.message ? err.message : String(err)); }); +} // ── slot lifecycle sync (claim→seat, release→free) ── function syncSeats() { @@ -258,6 +273,7 @@ function loop() { if (state.screen === 'mode') driveMode(pads); else if (state.screen === 'lobby') driveLobby(pads); updateDebug(); + updateControllerCount(); } catch (e) { console.error('[lobby] frame error (recovered):', e); const el = $('debug'); if (el) el.textContent = 'ERROR: ' + (e && e.message ? e.message : String(e)); @@ -274,9 +290,8 @@ async function boot() { $('btn-back-mode').addEventListener('click', () => { for (const id of state.bySlot.keys()) clearFb(id); showScreen('mode'); }); $('btn-choose-level').addEventListener('click', goLevel); $('btn-restart').addEventListener('click', () => enterMode(state.mode)); - $('btn-pair').addEventListener('click', () => { - manager.connectHidForSlot(SLOT_IDS[0]).catch((err) => { const el = $('debug'); if (el) el.textContent = 'pair: ' + err.message; }); - }); + $('btn-pair').addEventListener('click', pairController); + $('btn-pair-global').addEventListener('click', pairController); drawQR($('room-qr'), 'TNDM-7F3K'); setModeIdx(0); diff --git a/apps/overlay/src/lobby.html b/apps/overlay/src/lobby.html index 5f21c1c..2044d7d 100644 --- a/apps/overlay/src/lobby.html +++ b/apps/overlay/src/lobby.html @@ -111,6 +111,10 @@ .summary { text-align: center; font-size: 14px; line-height: 1.6; } .summary .big { font-size: 22px; color: var(--accent); display: block; margin-bottom: 4px; } + #pairbar { position: fixed; top: 7px; right: 12px; z-index: 101; display: flex; align-items: center; gap: 10px; -webkit-app-region: no-drag; } + #ctrl-count { font: 11px/1 var(--mono); color: #8a93a6; letter-spacing: .3px; } + .pairbtn-sm { font: 11px/1 var(--mono); color: #cdd6e6; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.18); border-radius: 8px; padding: 7px 11px; cursor: pointer; } + .pairbtn-sm:hover { color: #fff; border-color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.12); } #debug { position: fixed; left: 10px; bottom: 6px; right: 10px; font: 10px/1.4 var(--mono); color: #6a7184; white-space: pre; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } @media (prefers-reduced-motion: reduce) { .seat.joined,.seat.ready,.idle-chip,.cta.ready { animation: none; } } @@ -118,6 +122,10 @@
TANDEMONIUM · SEAT-SELECT NAVIGATOR
+
+ no controllers + +
From 70e0c93931fa26b00e761e1696e5907731ecf500 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Sun, 5 Jul 2026 21:32:08 -0400 Subject: [PATCH 07/17] overlay: WebHID-first controller selection + identity (Phase 3a) Make WebHID the authoritative input source for all HID controllers, with a manager-backed controller picker, per-unit identity, and full button parity. Controllers list / selection: - Detached, movable "Controllers" window (reuses HUD-window infra) listing every connected controller with live activity dots; click-to-select drives the overlay + auto-selects the 3D model (dropdown still overrides). - Manager-backed pool owns non-selected controllers; three states: AVAILABLE > ACTIVE (interacted) > SELECTED (the one shown). - Steam Puck's 5 HID interfaces roll up to one row; stable ordering (no reorder on select); selection binds the EXACT handle (identical vid:pid safe). - Auto-detect adopts only when nothing selected (startup + re-adopt on disconnect); pressing another controller never steals the selection. WebHID-first input (Phase 3a): - readGamepad() returns the HID synthetic whenever a HID driver is live (any transport), so every HID controller reads its exact handle with the complete button map. Gamepad API is now only a fallback for XInput-only pads (Xbox). Driver parsing / identity: - DualShock 4 buttons/sticks/triggers over WebHID (GameSir + real DS4). - Switch Pro buttons/sticks/triggers (report 0x30) + Capture button (slot 17); connection-type detection (USB 0x80 handshake report vs Bluetooth). - Per-unit serial/MAC per row; identical vid:pid disambiguated by connection type (Electron main HIDDevice.collections is empty, so no descriptor fp). - vid:pid disambiguation in identifyFromGamepadId. Tests: 116 passing (adds Switch Pro parse coverage). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YNqB4zV3F6aBAg6LUxuvBY --- apps/overlay/electron/main.js | 13 +- apps/overlay/electron/preload.js | 7 + apps/overlay/src/controllers-window.html | 49 +++ apps/overlay/src/index.html | 22 +- apps/overlay/src/js/app.js | 322 +++++++++++++++-- apps/overlay/src/js/controllers-window.js | 50 +++ apps/overlay/src/js/lobby-app.js | 320 ++++++++++++++--- apps/overlay/src/lobby.html | 51 ++- packages/core/src/drivers/base-driver.js | 8 + .../core/src/drivers/controller-registry.js | 17 +- .../core/src/drivers/playstation-driver.js | 64 +++- .../src/drivers/steam-controller-driver.js | 5 + .../core/src/drivers/switch-pro-driver.js | 61 ++++ packages/core/src/manager.js | 331 ++++++++++++++++-- .../core/test/controller-registry.test.js | 19 +- packages/core/test/helpers.js | 38 +- .../manager-duplicate-controllers.test.js | 184 ++++++++++ .../core/test/manager-hid-reconcile.test.js | 1 + .../core/test/manager-pool-liveness.test.js | 58 +++ .../core/test/manager-release-slot.test.js | 46 +++ packages/core/test/playstation-driver.test.js | 44 ++- packages/core/test/switch-pro-driver.test.js | 69 ++++ 22 files changed, 1617 insertions(+), 162 deletions(-) create mode 100644 apps/overlay/src/controllers-window.html create mode 100644 apps/overlay/src/js/controllers-window.js create mode 100644 packages/core/test/manager-duplicate-controllers.test.js create mode 100644 packages/core/test/manager-pool-liveness.test.js create mode 100644 packages/core/test/manager-release-slot.test.js create mode 100644 packages/core/test/switch-pro-driver.test.js diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index 51cf6df..cbfa9d8 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -115,8 +115,11 @@ function upsertHidController(d, connected) { function hidControllerList() { return [...hidControllers.values()]; } function broadcastHidControllers() { - if (inventoryWindow && !inventoryWindow.isDestroyed()) { - inventoryWindow.webContents.send('hid-controllers-snapshot', hidControllerList()); + const list = hidControllerList(); + // The inventory window and the lobby both render this (the lobby shows each + // seat's per-unit serial/MAC — see its Controllers panel). + for (const w of [inventoryWindow, mainWindow]) { + if (w && !w.isDestroyed()) w.webContents.send('hid-controllers-snapshot', list); } } @@ -357,6 +360,7 @@ app.whenReady().then(() => { gyro: { file: 'gyro-window.html', width: 260, height: 260 }, axis: { file: 'axis-window.html', width: 280, height: 90 }, roll: { file: 'roll-window.html', width: 240, height: 170 }, + controllers: { file: 'controllers-window.html', width: 320, height: 460 }, }; ipcMain.handle('open-hud-window', async (_event, { kind, profile, greenScreen }) => { @@ -423,6 +427,11 @@ app.whenReady().then(() => { if (w && !w.isDestroyed()) w.webContents.send('hud-state-update', state); }); + // Controllers window → main overlay: a row was clicked; switch the shown pad. + ipcMain.on('controller-select', (_event, payload) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('controller-select', payload); + }); + // A detached window's close button uses this to close itself (frameless // windows have no titlebar close affordance). ipcMain.on('close-this-window', (event) => { diff --git a/apps/overlay/electron/preload.js b/apps/overlay/electron/preload.js index b5b4d4e..269d218 100644 --- a/apps/overlay/electron/preload.js +++ b/apps/overlay/electron/preload.js @@ -64,4 +64,11 @@ contextBridge.exposeInMainWorld('electronAPI', { // Periodic snapshot of currently-present HID controllers (with serials). onHidControllersSnapshot: (callback) => ipcRenderer.on('hid-controllers-snapshot', (_, list) => callback(list)), + + // ── Controllers window → overlay selection ── + // The detached Controllers list sends the clicked controller's key; the main + // overlay receives it and switches which controller drives the visualization. + selectController: (payload) => ipcRenderer.send('controller-select', payload), + onControllerSelect: (callback) => + ipcRenderer.on('controller-select', (_, payload) => callback(payload)), }); diff --git a/apps/overlay/src/controllers-window.html b/apps/overlay/src/controllers-window.html new file mode 100644 index 0000000..a84c48a --- /dev/null +++ b/apps/overlay/src/controllers-window.html @@ -0,0 +1,49 @@ + + + + +Controllers + + + +
+
CONTROLLERS
+
+
click a controller to show it in the overlay · ● in use now · drag to move
+
+ + + + + diff --git a/apps/overlay/src/index.html b/apps/overlay/src/index.html index ff6af63..f8a63c3 100644 --- a/apps/overlay/src/index.html +++ b/apps/overlay/src/index.html @@ -101,6 +101,21 @@ letter-spacing: 0.5px; } + /* Controllers list (which controller drives the overlay) */ + #ovl-ctrl-list { display: flex; flex-direction: column; gap: 5px; max-height: 210px; overflow-y: auto; } + .ovl-ctrl-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); cursor: pointer; } + .ovl-ctrl-row:hover { border-color: rgba(255,255,255,0.28); } + .ovl-ctrl-row.sel { border-color: #6ce08f; box-shadow: 0 0 0 1px rgba(108,224,143,.4); } + .ovl-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: #39404e; transition: background .08s, box-shadow .08s; } + .ovl-dot.on { background: #6ce08f; box-shadow: 0 0 7px 1px #6ce08f; } + .ovl-ctrl-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } + .ovl-ctrl-name { font-size: 12px; font-weight: 600; color: #e7ecf5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ovl-ctrl-sub { font: 9px/1.3 ui-monospace, monospace; color: #8894a8; } + .ovl-tag { flex: 0 0 auto; font: 800 8px/1 ui-monospace, monospace; letter-spacing: .6px; padding: 3px 5px; border-radius: 4px; } + .ovl-tag.active { color: #06140c; background: #6ce08f; } + .ovl-tag.available { color: #c9d6ff; background: rgba(110,168,255,0.18); border: 1px solid rgba(110,168,255,0.4); } + .ovl-ctrl-empty { font: 10px/1.4 ui-monospace, monospace; color: #6a7184; text-align: center; padding: 8px; } + .setting-row { display: flex; align-items: center; @@ -919,7 +934,12 @@

Controller Overlay

- + + +
+ +
+