瀏覽代碼

Gyro fix and qam mapping

Jariullah Safi 4 月之前
父節點
當前提交
3fb3862df1

+ 5 - 3
OpenPuck/config.cpp

@@ -16,19 +16,20 @@ static uint8_t g_debugCdc = 0;   // persisted one-shot arm, stored in Cfg.rsvd0
 int     g_mDiv = 64, g_mFric = 94;
 uint8_t g_abSwap = 0;
 uint8_t g_back[4] = {5,6,7,8};   // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad U/D/L/R)
+uint8_t g_qamMap = 0;            // 0 = default (unmapped, uses hardcoded behavior per mode)
 
 // poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
 // persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC7   // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+#define CFG_MAGIC 0xC8   // bumped (qamMap): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], qamMap; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg(){
   Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,g_debugCdc,g_abSwap,
          {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
-         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
+         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]},g_qamMap};
   InternalFS.remove(CFG_FILE); File f(InternalFS);
   if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
 }
@@ -52,6 +53,7 @@ void loadCfg(){
       else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
       static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
       for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
+      g_qamMap = c.qamMap;
     }
     f.close();
   }

+ 2 - 1
OpenPuck/config.h

@@ -62,7 +62,8 @@ void armDebugCdcNextBoot();          // persist the one-shot (caller reboots)
 // persisted, runtime-tunable config:
 extern int     g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
 extern uint8_t g_abSwap;        // 1 = swap A/B and X/Y (Nintendo face-button layout)
-extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..11 buttons, 12..15 D-pad U/D/L/R)
+extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
+extern uint8_t g_qamMap;        // QAM (3 dots) physical button -> same code space (0 = default/unmapped)
 
 #define POLL_US_DEFAULT 4000u   // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
 #define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)

+ 3 - 1
OpenPuck/gamepad_util.cpp

@@ -56,10 +56,12 @@ static void psOrBackCode(uint32_t* b, uint8_t c){
   switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
     case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
     case 9:*b|=TB_VIEW; break; case 10:*b|=TB_MENU; break; case 11:*b|=TB_STEAM; break;
-    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; break; default: break; }
+    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; break;
+    case 16:*b|=TB_TOUCH; break; case 17:*b|=TB_MUTE; break; default: break; }
 }
 uint32_t psButtonsFromSteam(uint32_t raw){
   uint32_t b=raw;
+  if(g_qamMap && (b&TB_MENU)){ b &= ~(uint32_t)TB_MENU; b |= tritonFromCode(g_qamMap); }
   if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
   if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
   if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);

+ 11 - 1
OpenPuck/gamepad_util.h

@@ -6,6 +6,7 @@
 // are here.
 #pragma once
 #include <stdint.h>
+#include "triton.h"
 
 // int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
 uint8_t swStick(int16_t v, bool invert);
@@ -22,8 +23,17 @@ void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch, uint16_t lx, uint16_t
 void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
     uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry);
 
+// Convert configurable button code -> TB_* flag (shared across modes).
+static inline uint32_t tritonFromCode(uint8_t c){
+  switch(c){ case 1:return TB_A; case 2:return TB_B; case 3:return TB_X; case 4:return TB_Y;
+    case 5:return TB_LB; case 6:return TB_RB; case 7:return TB_L3; case 8:return TB_R3;
+    case 9:return TB_VIEW; case 10:return TB_MENU; case 11:return TB_STEAM;
+    case 12:return TB_DUP; case 13:return TB_DDN; case 14:return TB_DLF; case 15:return TB_DRT;
+    case 16:return TB_TOUCH; case 17:return TB_MUTE; default:return 0; }
+}
+
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
-uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard remapping
+uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard + QAM remapping
 uint8_t  psShouldersByte(uint32_t b);        // L1..R3 + Create/Options byte
 uint8_t  psHatNibble(uint32_t b);            // d-pad -> 8-way hat (8 = neutral)
 uint8_t  psFaceNibble(uint32_t b);           // face buttons (with A/B + X/Y swap)

+ 2 - 2
OpenPuck/mode_hidgyro.cpp

@@ -32,11 +32,11 @@ static void hidGyroBuild(uint8_t out[63]){
   out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
   out[4]=psHatNibble(b)|psFaceNibble(b);
   out[5]=psShouldersByte(b);
-  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
+  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
   out[7]=g_in.lt; out[8]=g_in.rt;
   out[12]=g_in.gx&0xFF; out[13]=g_in.gx>>8;
   out[14]=g_in.gy&0xFF; out[15]=g_in.gy>>8;
-  out[16]=g_in.gz&0xFF; out[17]=g_in.gz>>8;
+  out[16]=(-g_in.gz)&0xFF; out[17]=(-g_in.gz)>>8;
   out[18]=g_in.ax&0xFF; out[19]=g_in.ax>>8;
   out[20]=g_in.ay&0xFF; out[21]=g_in.ay>>8;
   out[22]=g_in.az&0xFF; out[23]=g_in.az>>8;

+ 2 - 0
OpenPuck/mode_lizard.cpp

@@ -1,9 +1,11 @@
 #include "mode_lizard.h"
 #include "triton.h"
 #include "config.h"
+#include "gamepad_util.h"
 
 void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
   uint32_t b=btnsOf(r);
+  if(g_qamMap && (b&TB_MENU)){ b &= ~(uint32_t)TB_MENU; b |= tritonFromCode(g_qamMap); }
   // --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
   static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
   bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);

+ 2 - 2
OpenPuck/mode_ps5.cpp

@@ -42,9 +42,9 @@ static void ps5Build(uint8_t out[63]){
   static uint8_t seq=0; out[6]=seq++;
   out[7]=psHatNibble(b)|psFaceNibble(b);
   out[8]=psShouldersByte(b);
-  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_LPADC||b&TB_RPADC)?0x02:0);
+  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_MUTE)?0x04:0);
   out[15]=g_in.gx&0xFF; out[16]=g_in.gx>>8;
-  out[17]=g_in.gz&0xFF; out[18]=g_in.gz>>8;
+  out[17]=(-g_in.gz)&0xFF; out[18]=(-g_in.gz)>>8;
   out[19]=g_in.gy&0xFF; out[20]=g_in.gy>>8;
   out[21]=g_in.ax&0xFF; out[22]=g_in.ax>>8;
   out[23]=g_in.ay&0xFF; out[24]=g_in.ay>>8;

+ 1 - 0
OpenPuck/mode_switch_hori.cpp

@@ -38,6 +38,7 @@ static inline void backCodeToHatDirs(uint8_t c, bool& u, bool& d, bool& l, bool&
 // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
 static void switchBuildHoripad(uint8_t out[8]){
   uint32_t b=g_in.buttons; uint16_t btn=0;
+  if(g_qamMap && (b&TB_MENU)){ b &= ~(uint32_t)TB_MENU; b |= tritonFromCode(g_qamMap); }
   // Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
   if((b&CHORD_BACK4)==CHORD_BACK4) b &= ~(uint32_t)(TB_A|TB_X|TB_Y);
   // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)

+ 2 - 0
OpenPuck/mode_switch_pro.cpp

@@ -1,5 +1,6 @@
 #include "mode_switch_pro.h"
 #include "triton.h"
+#include "gamepad_util.h"
 #include "config.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -64,6 +65,7 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){   // pack two 12-bi
 static uint8_t g_jcTimer=0;
 static void jcInputPrefix(uint8_t* out){
   uint32_t b=g_in.buttons;
+  if(g_qamMap && (b&TB_MENU)){ b &= ~(uint32_t)TB_MENU; b |= tritonFromCode(g_qamMap); }
   if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
   uint32_t fA=g_abSwap?JC_BTN_B:JC_BTN_A, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
   uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;

+ 2 - 0
OpenPuck/mode_xinput.cpp

@@ -1,5 +1,6 @@
 #include "mode_xinput.h"
 #include "triton.h"
+#include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
@@ -105,6 +106,7 @@ static uint16_t codeToXB(uint8_t c){
 }
 static void rfXboxGamepad(const uint8_t* r){
   uint32_t b=btnsOf(r);
+  if(g_qamMap && (b&TB_MENU)){ b &= ~(uint32_t)TB_MENU; b |= tritonFromCode(g_qamMap); }
   uint16_t btn=0;                                   // map Triton buttons -> XInput button word
   if(b&TB_DUP)btn|=XB_DUP;   if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
   if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;

+ 2 - 1
OpenPuck/serial_console.cpp

@@ -59,7 +59,8 @@ void serialConsolePoll(){
       else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
       else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",g_mFric); }
       else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
-      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
+      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
+      else if (line[0]=='Q'){ uint8_t code=strtoul(line+1,0,10); g_qamMap=code; saveCfg(); Serial.printf("# QAM -> code %u  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",code); }
       else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
         uint8_t pl[3]={id,(uint8_t)(val&0xFF),(uint8_t)(val>>8)}; relayEnqueue(0x87,pl,3);
         Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }

+ 2 - 0
OpenPuck/triton.h

@@ -29,6 +29,8 @@
 #define TB_LB     0x80000u
 #define TB_RPADT  0x200000u
 #define TB_RPADC  0x400000u
+#define TB_TOUCH  0x100000u      // virtual: PS Touch Click (back-paddle/QAM target, not a real Triton button)
+#define TB_MUTE   0x1000000u     // virtual: PS5 Mute button (back-paddle/QAM target, not a real Triton button)
 #define TB_LPADT  0x2000000u
 #define TB_LPADC  0x4000000u
 

+ 2 - 1
OpenPuck/webusb_config.cpp

@@ -23,7 +23,7 @@ static void webusbSendBlob(){
   uint8_t p[2+WB_PAYLEN];
   p[0]=0xA5; p[1]=WB_PAYLEN;
   p[2]=4;                          // protocol version (4 = +battery/rssi/git; 2 = chordBtn[3])
-  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
+  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=g_qamMap; p[7]=g_abSwap;
   p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
   p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
   p[13]=up?1:0;
@@ -116,6 +116,7 @@ void webusbPoll(){
           case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
           case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
           case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
+          case 21: g_qamMap = v; break;   // QAM physical button remap code (0=default/unmapped)
         }
         if(persist) saveCfg();
         webusbSendBlob();

+ 10 - 3
docs/index.html

@@ -121,7 +121,8 @@
       <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
       <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
       <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
-      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
+      <div class="row"><label>QAM (3 dots)</label><select id="qamMap"></select></div>
+      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes. QAM remap applies to all modes (0 = default per-mode behavior).</p>
     </div>
 
     <div class="card logonly">
@@ -143,7 +144,7 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
 const CHORD_FIELD = [17, 18, 19];
-const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"]];
+const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
@@ -152,6 +153,10 @@ function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString
 for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
+{ const sel = document.getElementById("qamMap");
+  const d = document.createElement("option"); d.value=0; d.textContent="Default (varies by mode)"; sel.appendChild(d);
+  for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+}
 for(const sel of document.querySelectorAll("select.chord")){
   MODE_NAMES.forEach((n,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
@@ -211,7 +216,7 @@ async function readBlob(){
 }
 function applyBlob(p){
   // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], abSwap=p[5];   // p[4] reserved (ex-padSmooth)
+  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
   const back=[p[6],p[7],p[8],p[9]], 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,2];   // v2 blob: chordBtn after qos/persist
@@ -251,6 +256,7 @@ function applyBlob(p){
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
+  { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
 }
 function fmtSlider(id,v){ return ""+v; }
@@ -364,6 +370,7 @@ for(const id of ["mDiv","mFric"]){
 for(const sel of document.querySelectorAll("select.back")){
   sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
 }
+$("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").value));
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }