Jari 2 місяців тому
батько
коміт
303dc02993
7 змінених файлів з 116 додано та 28 видалено
  1. 7 5
      CODE_MAP.md
  2. 32 3
      OpenPuck/config.cpp
  3. 8 0
      OpenPuck/config.h
  4. 5 5
      OpenPuck/rf_link.cpp
  5. 19 3
      OpenPuck/webusb_config.cpp
  6. 2 1
      docs/TESTING_GUIDE.md
  7. 43 11
      docs/index.html

+ 7 - 5
CODE_MAP.md

@@ -69,14 +69,16 @@ re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
 ## 2. Configuration & identity
 
 ### `config.cpp` / `config.h` — persisted settings (loop task)
-- **Owns**: `g_usbMode`, `g_xbox`, `g_chordBtn[3]`, `g_persistMode`, `g_bootMode`,
+- **Owns**: `g_usbMode`, `g_xbox`, `g_chordBtn[3]`, `g_chordDpad[4]`, `g_persistMode`, `g_bootMode`,
   `g_debugCdcThisBoot`, `g_mDiv`, `g_mFric`, `g_type[ET_COUNT]` (per-emulated-type
   button config), `g_etype`, live mirrors `g_abSwap`/`g_back[4]`/`g_qamMap`/
   `g_padHaptics`/`g_ledBright`, `g_rumbleScale`, `g_swProRate`, `g_swGyroScale10`,
   and the constant `g_pollUs = 4000` (250 Hz, **fixed**).
-- `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCD`. `rxWin10`,
-  `hapticBlockOn`, `hapticBlockS` and the per-type table travel with it; `rsvd0` is the
-  one-shot debug-CDC arm.
+- `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCF`. `rxWin10`,
+  `lizKeep`, `landAll87` and the per-type table travel with it; `rsvd0` is the
+  one-shot debug-CDC arm. New fields are **appended to the tail** (`chordDpad[4]`) —
+  `loadCfg()` prefills `0xFF` and accepts a file short by only the tail (`CFG_LEN_MIN`),
+  so an upgrade keeps every existing setting and unwritten tail bytes fall back to defaults.
 - `loadCfg()` resolves the boot mode policy: one-shot `bootMode` wins once then clears;
   else `persistMode ? last mode : STEAM(0)`. Calls `applyActiveType()`.
 - `saveCfg`, `saveMode`, `armDebugCdcNextBoot`, `factoryErase` (LittleFS `format()`),
@@ -148,7 +150,7 @@ The "dongle" role. All driven synchronously from `rfLinkTask()` in `loop()`.
   samples RSSI, updates `g_connReplyMs`/`g_linkRssi`, calls `hapticOnReconnect` on a
   gap, and **decodes the 0x45 input report into `g_in[g_curSlot]`** (buttons, sticks,
   triggers, trackpads, IMU). Also handles: Steam-button short-press remote wakeup,
-  Steam+Y 2 s power-off chord, back-4 mode-switch chord (`saveMode` + `NVIC_SystemReset`),
+  Steam+Y 2 s power-off chord, back-4 mode-switch chord (face + D-pad, `saveMode` + `NVIC_SystemReset`),
   and status reports 0x43 (battery → `g_battery`/`g_batteryState`) / 0x44 → dispatched
   via `g_active->onAuxReport`.
 - **`g_curSlot`** — the slot the poll loop is currently driving. Set by `rfConnStep`

+ 32 - 3
OpenPuck/config.cpp

@@ -4,6 +4,7 @@
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
 #include <string.h>
+#include <stddef.h> // offsetof (CFG_LEN_MIN, the short-file accept threshold)
 using namespace Adafruit_LittleFS_Namespace;
 
 uint8_t g_usbMode = 0;
@@ -11,6 +12,11 @@ bool g_xbox = false;
 uint8_t g_chordBtn[3] = {
 	MODE_LIZARD, MODE_XBOX, MODE_SW_PRO
 }; // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
+// back4+D-pad (left/up/right/down). Defaults are the console personalities that ship WITHOUT a config
+// interface -- those modes can't be entered from the panel-less side any other way, and back4+A still returns
+// to Steam. Configurable like g_chordBtn (WebUSB fields 34..37).
+uint8_t g_chordDpad[4] = { MODE_PS3, MODE_DS4_GAME, MODE_PS5_GAME,
+			   MODE_SW_HORI };
 bool g_persistMode = false;
 uint8_t g_bootMode = 0xFF;
 
@@ -86,8 +92,16 @@ struct Cfg {
 	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
+	// TAIL (appended after CFG_MAGIC 0xCF shipped): back4+D-pad mode assignments. New tail fields go HERE, at
+	// the end, and loadCfg accepts a short file so an upgrade keeps every existing setting -- see CFG_LEN_MIN.
+	uint8_t chordDpad[4];
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
+// Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
+// A file that stops anywhere in the tail leaves those bytes at the 0xFF prefill loadCfg() applies, which every
+// tail field treats as "unset" and replaces with its default.
+#define CFG_LEN_MIN (offsetof(struct Cfg, chordDpad))
+
 void saveCfg()
 {
 	Cfg c = { CFG_MAGIC,
@@ -103,7 +117,9 @@ void saveCfg()
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  g_landAll87,
-		  {} };
+		  {},
+		  { g_chordDpad[0], g_chordDpad[1], g_chordDpad[2],
+		    g_chordDpad[3] } };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
 	InternalFS.remove(CFG_FILE);
@@ -117,11 +133,17 @@ void saveCfg()
 void loadCfg()
 {
 	Cfg c;
+	// 0xFF prefill: bytes a SHORT (pre-tail) cfg.bin never wrote stay 0xFF, which is not a valid mode/flag, so
+	// each tail field below falls back to its compiled default instead of reading whatever was on the stack.
+	memset(&c, 0xFF, sizeof c);
 	File f(InternalFS);
 	bool consume = false;
 	if (f.open(CFG_FILE, FILE_O_READ)) {
-		if (f.read((uint8_t *)&c, sizeof c) == (int)sizeof c &&
-		    c.magic == CFG_MAGIC) {
+		int got = f.read((uint8_t *)&c, sizeof c);
+		// Accept a file that is short only in the appended tail (>= CFG_LEN_MIN): an upgrade from a build
+		// predating the tail keeps mode/paddles/chords instead of silently reverting to factory defaults.
+		// Anything shorter, or a stale magic, is a real layout change -> discard and use defaults.
+		if (got >= (int)CFG_LEN_MIN && c.magic == CFG_MAGIC) {
 			g_mDiv = c.mDiv ? c.mDiv : 64;
 			g_mFric = c.mFric;
 			for (int i = 0; i < ET_COUNT; i++)
@@ -154,6 +176,13 @@ void loadCfg()
 				g_chordBtn[i] = modeValid(c.chordBtn[i]) ?
 							c.chordBtn[i] :
 							CHORD_DEF[i];
+			// D-pad chords: 0xFF (short pre-tail file) or any invalid mode keeps the compiled default.
+			for (int i = 0; i < 4; i++)
+				if (modeValid(c.chordDpad[i]))
+					g_chordDpad[i] = c.chordDpad[i];
+			// grow a short file to the current layout on the next save
+			if (got < (int)sizeof c)
+				consume = true;
 
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;

+ 8 - 0
OpenPuck/config.h

@@ -96,6 +96,14 @@ extern uint8_t g_usbMode; // loaded from flash at boot
 extern bool g_xbox;
 extern uint8_t g_chordBtn[3]; // back4+B/X/Y -> these modes (A always STEAM)
 
+// back4+D-pad -> these modes, indexed CHD_LEFT/UP/RIGHT/DOWN. Same deal as g_chordBtn: configurable from the
+// panel, defaults reach the console modes that have no config interface (so a controller-only route in exists).
+#define CHD_LEFT 0
+#define CHD_UP 1
+#define CHD_RIGHT 2
+#define CHD_DOWN 3
+extern uint8_t g_chordDpad[4];
+
 // Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
 // mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared). g_persistMode
 // instead remembers the last selected mode across reboots.

+ 5 - 5
OpenPuck/rf_link.cpp

@@ -695,7 +695,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					idx += tlen + 2;
 				}
 				// mode-switch chord (back4 + face/dpad): A=always Steam; B/X/Y=configurable (g_chordBtn[]);
-				// dpad left=PS3, up=DS4 game, right=PS5 game, down=Switch HORI. Debounced.
+				// dpad left/up/right/down=configurable (g_chordDpad[], defaults PS3/DS4/PS5/Switch). Debounced.
 				{
 					// Per-slot debounce: the chord input is per-slot (g_in[g_curSlot]), so the debounce counter
 					// must be too. The shared-static form worked with 1 controller because slot 0 polled
@@ -724,16 +724,16 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							want = g_chordBtn[2];
 						else if (g_in[g_curSlot].buttons &
 							 TB_DLF)
-							want = MODE_PS3;
+							want = g_chordDpad[CHD_LEFT];
 						else if (g_in[g_curSlot].buttons &
 							 TB_DUP)
-							want = MODE_DS4_GAME;
+							want = g_chordDpad[CHD_UP];
 						else if (g_in[g_curSlot].buttons &
 							 TB_DRT)
-							want = MODE_PS5_GAME;
+							want = g_chordDpad[CHD_RIGHT];
 						else if (g_in[g_curSlot].buttons &
 							 TB_DDN)
-							want = MODE_SW_HORI;
+							want = g_chordDpad[CHD_DOWN];
 					}
 					if (want != 0xFF &&
 					    want == chWant[g_curSlot]) {

+ 19 - 3
OpenPuck/webusb_config.cpp

@@ -55,7 +55,8 @@ static void fwupAckPost(uint8_t status)
 //                [v13: per-slot link stats, 4x9B from p[145]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
-#define WB_PAYLEN 180
+//                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
+#define WB_PAYLEN 184
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -80,13 +81,14 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (17 = per-type rumble field (TypeCfg k=8), per-type stride 8->9; 16 = +configurable
+	// protocol version (18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
+	// 17 = per-type rumble field (TypeCfg k=8), per-type stride 8->9; 16 = +configurable
 	// lizard-map ops 0x11..0x15 / 0xAA frame, payload unchanged -- the panel MUST see >=16 before it dares
 	// send 0x11, or a blocking readLizard() would hang forever against a firmware that silently drops the
 	// unknown op; 15 = +staged firmware-update ops 0x20..0x24; 14 = +landAll87 toggle; 13 = +per-slot link
 	// stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type
 	// cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 17;
+	p[2] = 18;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -255,6 +257,11 @@ static void webusbSendBlob()
 	}
 	// v14/v17: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	p[181] = g_landAll87;
+	// v18: back4+D-pad mode assignments (panel renders these as selects next to the B/X/Y ones)
+	p[182] = g_chordDpad[CHD_LEFT];
+	p[183] = g_chordDpad[CHD_UP];
+	p[184] = g_chordDpad[CHD_RIGHT];
+	p[185] = g_chordDpad[CHD_DOWN];
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -925,6 +932,15 @@ void webusbPoll()
 						g_chordBtn[f - 17] = v;
 					break;
 
+				// back4+D-pad mode assignments (left/up/right/down), protocol v18
+				case 34:
+				case 35:
+				case 36:
+				case 37:
+					if (modeValid(v))
+						g_chordDpad[f - 34] = v;
+					break;
+
 				// reboot once WITH the CDC serial console (puck mode), then auto-revert
 				case 20:
 					armDebugCdcNextBoot();

+ 2 - 1
docs/TESTING_GUIDE.md

@@ -5,7 +5,8 @@
 - Does the controlelr work normally in Steam Controller mode, do the haptics work, is the gyro working?
 - Can you shut the controller off using Steam + Y?
 - Does the controller reconnect?
-- Can you change modes via the chords?
+- Can you change modes via the chords? (back4 + A/B/X/Y **and** back4 + each D-pad direction; the chorded
+  press must not reach the game, and a D-pad reassignment made in the panel must take effect)
 - Can you change modes via webusb?
 - Do the modes work as expected (including gyro in gyro modes)
 - Is signal quality shown?

+ 43 - 11
docs/index.html

@@ -166,11 +166,21 @@
     </div>
 
     <div class="card">
-      <h2>Back4 + face button chords</h2>
-      <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
-      <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
-      <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
-      <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
+      <h2>Back4 chords (face + D-pad)</h2>
+      <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button or D-pad direction to switch mode without the WebUI. <strong>A is always Steam</strong> — that is the way back out of any mode. B / X / Y and all four D-pad directions are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch Pro).</p>
+      <div class="row"><label>back4 + B</label><select class="chord" data-i="0"></select></div>
+      <div class="row"><label>back4 + X</label><select class="chord" data-i="1"></select></div>
+      <div class="row"><label>back4 + Y</label><select class="chord" data-i="2"></select></div>
+      <!-- D-pad chords (firmware >= v18). Unlike the face selects these offer the game/clean modes too: those
+           modes have no WebUSB interface, so a chord is the only way IN, and back4+A is always the way out.
+           Older firmware has no D-pad chords at all -> the whole block hides (see #dpadChords in applyBlob). -->
+      <div id="dpadChords" class="hide">
+        <div class="row"><label>back4 + Left</label><select class="chordD" data-i="0"></select></div>
+        <div class="row"><label>back4 + Up</label><select class="chordD" data-i="1"></select></div>
+        <div class="row"><label>back4 + Right</label><select class="chordD" data-i="2"></select></div>
+        <div class="row"><label>back4 + Down</label><select class="chordD" data-i="3"></select></div>
+      </div>
+      <p id="dpadOld" class="note hide">This puck's firmware predates the D-pad chords — update it to configure them.</p>
     </div>
 
     <div class="card wide">
@@ -370,7 +380,9 @@
 
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)"];
-const CHORD_FIELD = [17, 18, 19];
+const CHORD_FIELD = [17, 18, 19];          // back4 + B/X/Y
+const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
+const CHORD_DPAD_DEF = [9, 8, 7, 2];       // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
 // A ReversePuck CONTROLLER dongle (28DE:1302) speaks a REDUCED subset of this protocol: no status blob /
 // lizard / config, just a paired-pucks list (0xAC) plus the identical firmware-update + DFU ops. When true,
@@ -644,6 +656,12 @@ for(const sel of document.querySelectorAll("select.chord")){
   MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
+// D-pad chords offer EVERY mode, including the game/clean ones the face selects skip: those modes drop WebUSB,
+// so a chord is the only way into them, and back4+A still gets you back to Steam.
+for(const sel of document.querySelectorAll("select.chordD")){
+  MODE_NAMES.forEach((n,i)=>{
+    const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
+}
 { const sel=document.getElementById("swRate");
   for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
@@ -795,7 +813,7 @@ async function readFrame(marker, minLen, readLen){
   try{
     // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
     // readLen is overridable: the dongle's 0xAC paired-pucks list reaches 213 B (8 pucks) and needs 256.
-    const r = await dev.transferIn(epIn, readLen||192);
+    const r = await dev.transferIn(epIn, readLen||256);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
@@ -826,6 +844,9 @@ function applyBlob(p){
   const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
+  // D-pad chords land at the tail of the v18 payload (firmware p[182..185] = payload p[180..183]).
+  const dpadCap=(p[0]>=18 && p.length>183);
+  const chordD=dpadCap?[p[180],p[181],p[182],p[183]]:CHORD_DPAD_DEF;
   const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
   const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
   const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
@@ -1053,6 +1074,9 @@ function applyBlob(p){
   { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
   { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
+  $("#dpadChords").classList.toggle("hide", !dpadCap);
+  $("#dpadOld").classList.toggle("hide", dpadCap);
+  if(dpadCap) for(const sel of document.querySelectorAll("select.chordD")){ if(document.activeElement!==sel) sel.value=chordD[+sel.dataset.i]; }
 }
 function fmtSlider(id,v){ return id==="rumble" ? v+"%" : id==="hapBlockS" ? v+" s" : ""+v; }
 function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
@@ -1148,6 +1172,9 @@ function buildBackup(p, bp){
     types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7], rumble:p[q+8]}); }
   const cfg={ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
               rumble:p[51], swProRate:p[52], swGyro10:p[53], types };
+  // D-pad chords only exist from protocol v18; omit the key entirely on older pucks so a restore doesn't
+  // replay defaults over a newer puck's real settings.
+  if(p[0]>=18 && p.length>183) cfg.chordD=[p[180],p[181],p[182],p[183]];
   // version 2: include the lizard map so it survives a backup/restore cycle.
   // Guard on !lizardBusy to avoid a race with the one-shot lazy load (lizardExchange sets
   // lizardBusy while the initial 0x11 fetch is in flight); if the load is still pending the
@@ -1194,11 +1221,11 @@ async function importBackup(file){
   // driven while the import replays every setting onto the puck, then closes on Done / on error.
   modalOpen("Restore backup");
   const c=obj.config;
-  // Pre-count every write so the bar tracks real progress: settings (9 base fields), per-type config
-  // (9 each), the lizard bindings, 4 bond slots, and the final commit.
+  // Pre-count every write so the bar tracks real progress: settings (9 base fields + 4 D-pad chords when the
+  // backup carries them), per-type config (9 each), the lizard bindings, 4 bond slots, and the final commit.
   const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
   const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
-  const total=9 + typeN*9 + lizardN + 4 + 1;
+  const total=9 + (Array.isArray(c.chordD)?4:0) + typeN*9 + lizardN + 4 + 1;
   let step=0, stage="";
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
@@ -1222,6 +1249,8 @@ async function importBackup(file){
     stage="Restoring settings"; tick();
     await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
     await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
+    // absent in backups taken before the D-pad chords existed (or from a pre-v18 puck) -- skip, don't default
+    if(Array.isArray(c.chordD)) for(let i=0;i<4;i++) await sf(CHORD_DPAD_FIELD[i], c.chordD[i]);
     await sf(23,c.swProRate); await sf(24,c.swGyro10);
     // c.types is absent in some very old backups created before per-type paddle/haptic config
     // was added to the panel export; guard so a missing field never crashes the import mid-flight.
@@ -2149,10 +2178,13 @@ $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }
+for(const sel of document.querySelectorAll("select.chordD")){
+  sel.addEventListener("change", ()=>setField(CHORD_DPAD_FIELD[+sel.dataset.i], +sel.value));
+}
 for(const b of document.querySelectorAll(".modebtn")){
   b.onclick=()=>{ const m=+b.dataset.mode;
     const clean=(m===7||m===8||m===9);
-    const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
+    const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold all four back paddles (L4+R4+L5+R5) + A to return to Steam mode. The back4+D-pad chords are how you get back INTO these modes without the panel — check their assignments in the chords card first.":"");
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
 updateUf2UI();