From 1b1944c0062a35859e9ccfccdf70f57d7250b0cf Mon Sep 17 00:00:00 2001 From: MarcelineVPQ Date: Sun, 24 May 2026 10:42:29 -0600 Subject: [PATCH] feat(remap): on-dongle button remapping over 0xF6/0xF7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16-entry source→target table in its own flash sector (-3, magic DS5\x03), identity default, 0xFF = disabled. Applied to the OUTGOING host report copy only — raw interrupt_in_data (OLED screens + PS+Mute reboot combo) stays physical. Edited via the existing 0xF6/0xF7 vendor reports with a hardened RM+version frame (no HID-descriptor change, so Windows enumeration is unaffected) plus a revision counter the host polls to confirm a write. Apply logic + button set ported from SundayMoments/DS5_Bridge (credit). scripts/remap_test.py exercises the path over /dev/hidraw without the web tool. Verified on hardware: swap applied, read back, survived reboot. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + CMakeLists.txt | 1 + scripts/remap_test.py | 139 +++++++++++++++++++++++++++++ src/cmd.cpp | 51 ++++++++++- src/main.cpp | 12 ++- src/remap.cpp | 197 ++++++++++++++++++++++++++++++++++++++++++ src/remap.h | 43 +++++++++ 7 files changed, 443 insertions(+), 4 deletions(-) create mode 100755 scripts/remap_test.py create mode 100644 src/remap.cpp create mode 100644 src/remap.h diff --git a/CHANGELOG.md b/CHANGELOG.md index b488b03..14ea6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- **Button remapping.** Any of the 16 digital controls (face buttons, D-pad, shoulders/triggers, stick clicks, Create/Options) can be remapped to any other — stored on the dongle, applied transparently before the host sees the report, so it works on every OS and every game with no host-side software. The remap table lives in its own dedicated flash sector (`PICO_FLASH_SIZE_BYTES - 3·FLASH_SECTOR_SIZE`, magic `DS5\x03`, below the slots sector) and survives reboot; identity (no remap) is the default. Multiple sources mapping to one target OR together (analog L2/R2 take the max); a source can be set to *disabled* (`0xFF`). The remap acts on the **outgoing host report copy only** — the raw input the OLED screens and the PS+Mute reboot combo read is untouched. Edited over the existing `0xF6`/`0xF7` vendor reports with a hardened `RM`+version frame (**no HID-descriptor change**, so Windows enumeration is unaffected) and a revision counter the host polls to confirm a write landed. New `src/remap.{h,cpp}`; apply logic + button set ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Dev helper `scripts/remap_test.py` exercises the path over `/dev/hidraw` without the web tool. + ### Changed - **BT microphone now has packet-loss concealment (PLC).** The mic decode path gained a small decoded-frame jitter buffer (8 frames) drained at a steady 10 ms playout cadence: bursty BT delivery is smoothed, and a dropped mic frame during an active session is concealed with an Opus PLC frame (`opus_decode(decoder, NULL, 0, …)`) instead of leaving a hole the host hears as a click/dropout. Playout pre-buffers 3 frames and stops after 300 ms of no real frames (so it never emits comfort noise when the mic is idle). A new **`Mic PLC:`** counter on the Diagnostics screen climbs only when concealment fires — effectively a live BT link-quality gauge. Verified: forced BT loss kept the captured audio gap-free (longest zero-run ~0 ms) while the counter climbed. Design ported from [SundayMoments/DS5_Bridge](https://github.com/SundayMoments/DS5_Bridge) (credit). Adds ~30 ms mic latency (the pre-buffer). diff --git a/CMakeLists.txt b/CMakeLists.txt index 80c876a..3ef99f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable(ds5-bridge src/state_mgr.cpp src/oled.cpp src/slots.cpp + src/remap.cpp ) if (ENABLE_BATT_LED) diff --git a/scripts/remap_test.py b/scripts/remap_test.py new file mode 100755 index 0000000..58e613f --- /dev/null +++ b/scripts/remap_test.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Host-side dev helper to exercise the firmware button-remap path over the +already-declared 0xF6 (SET) / 0xF7 (GET) vendor feature reports — no web tool +needed. Linux only (reads /dev/hidraw directly), same role as mic_diag.sh. + +The remap rides 0xF6/0xF7 with a magic+version frame (see src/cmd.cpp): + SET 0xF6: [func=0x10]['R']['M'][ver][table[16]] + GET 0xF7: ['R']['M'][ver][rev_lo][rev_hi][table[16]] + +Usage: + remap_test.py get # show revision + current table + remap_test.py swap CIRCLE CROSS # swap two buttons (and persist) + remap_test.py set SRC TGT # route SRC -> TGT (one-way) + remap_test.py off SRC # disable SRC (0xFF) + remap_test.py reset # identity (no remap) + +Run with sudo if /dev/hidraw needs root. +""" +import fcntl, glob, struct, sys + +VID, PID = 0x054c, 0x0ce6 +CONFIG_LEN = 35 # sizeof(Config_body), see src/config.h +PROTO_VER = 1 # kRemapProtoVer +COUNT = 16 # kRemapCount + +# Must match RemapButton order in src/remap.cpp. +NAMES = ["L2", "L1", "CREATE", "DPAD_UP", "DPAD_LEFT", "DPAD_DOWN", + "DPAD_RIGHT", "L3", "R2", "R1", "OPTIONS", "TRIANGLE", + "CIRCLE", "CROSS", "SQUARE", "R3"] +IDX = {n: i for i, n in enumerate(NAMES)} + + +def hidiocg(size): # HIDIOCGFEATURE(size) + return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x07 + + +def hidiocs(size): # HIDIOCSFEATURE(size) + return (3 << 30) | (size << 16) | (ord('H') << 8) | 0x06 + + +def read_f7(f): + """Return (rev, table[16]) or None if the response lacks the remap block.""" + size = 64 # 1 report-id byte + up to 63 payload + buf = bytearray(size) + buf[0] = 0xF7 + fcntl.ioctl(f, hidiocg(size), buf) + payload = bytes(buf[1:]) # kernel prepends report id at byte 0 + blk = payload[CONFIG_LEN:CONFIG_LEN + 5 + COUNT] + if len(blk) < 5 + COUNT or blk[0] != ord('R') or blk[1] != ord('M'): + return None + ver = blk[2] + rev = blk[3] | (blk[4] << 8) + table = list(blk[5:5 + COUNT]) + return ver, rev, table + + +def find_dongle(): + for path in sorted(glob.glob('/dev/hidraw*')): + try: + f = open(path, 'rb+', buffering=0) + except (OSError, PermissionError): + continue + try: + if read_f7(f) is not None: + return f, path + except OSError: + pass + f.close() + return None, None + + +def write_table(f, table): + assert len(table) == COUNT + payload = bytes([0xF6, 0x10, ord('R'), ord('M'), PROTO_VER]) + bytes(table) + buf = bytearray(payload) + fcntl.ioctl(f, hidiocs(len(buf)), buf) + + +def show(label, ver, rev, table): + print(f"{label}: proto v{ver}, revision {rev}") + active = [(s, t) for s, t in enumerate(table) if t != s] + if not active: + print(" identity (no remap active)") + return + for s, t in active: + tn = "DISABLED" if t == 0xFF else NAMES[t] + print(f" {NAMES[s]:<10} -> {tn}") + + +def parse_button(arg): + key = arg.upper() + if key not in IDX: + sys.exit(f"unknown button '{arg}'. choices: {', '.join(NAMES)}") + return IDX[key] + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(2) + cmd = sys.argv[1].lower() + + f, path = find_dongle() + if f is None: + sys.exit("no DS5 dongle with remap support found " + "(check /dev/hidraw permissions and firmware version)") + print(f"dongle: {path}") + ver, rev, table = read_f7(f) + + if cmd == "get": + show("current", ver, rev, table) + return + + if cmd == "reset": + table = list(range(COUNT)) + elif cmd == "off" and len(sys.argv) == 3: + table[parse_button(sys.argv[2])] = 0xFF + elif cmd == "set" and len(sys.argv) == 4: + table[parse_button(sys.argv[2])] = parse_button(sys.argv[3]) + elif cmd == "swap" and len(sys.argv) == 4: + a, b = parse_button(sys.argv[2]), parse_button(sys.argv[3]) + table[a], table[b] = b, a + else: + print(__doc__) + sys.exit(2) + + show("writing", ver, rev, table) + write_table(f, table) + nver, nrev, ntable = read_f7(f) + show("read-back", nver, nrev, ntable) + if ntable == table and nrev != rev: + print("OK: table applied and revision bumped") + else: + print("WARNING: read-back mismatch or revision did not change") + + +if __name__ == "__main__": + main() diff --git a/src/cmd.cpp b/src/cmd.cpp index 2d5378f..5d0b793 100644 --- a/src/cmd.cpp +++ b/src/cmd.cpp @@ -14,6 +14,7 @@ #include "device/usbd.h" #include "pico/time.h" #include "slots.h" +#include "remap.h" #include "hardware/clocks.h" #include "hardware/adc.h" #include "hardware/vreg.h" @@ -86,11 +87,34 @@ bool is_pico_cmd(uint8_t report_id) { uint16_t pico_cmd_get(uint8_t report_id, uint8_t *buffer, uint16_t reqlen) { if (report_id == 0xf7) { printf("[HID] Receive 0xf7 getting config\n"); - if (sizeof(Config_body) > reqlen) { + const size_t cfg_len = sizeof(Config_body); + if (cfg_len > reqlen) { printf("[Config] Warning: Config_body overflow\n"); } - const auto len = std::min(sizeof(Config_body),static_cast(reqlen)); - memcpy(buffer,&get_config(),len); + const auto len = std::min(cfg_len, static_cast(reqlen)); + memcpy(buffer, &get_config(), len); + + // OLED Edition: append the button-remap block right after Config_body + // when the host asked for enough room. Old clients request exactly + // sizeof(Config_body) and never see it; new web tools read config + + // remap in one GET (the 0xF6/0xF7 reports are 63 bytes, plenty). + // [+0] 'R' + // [+1] 'M' + // [+2] protocol version (kRemapProtoVer) + // [+3..+4] revision uint16 LE (bumps on each successful set) + // [+5..+20] 16-byte remap table (source idx -> target idx, 0xFF=off) + constexpr size_t kRemapBlock = 5 + kRemapCount; + if (reqlen >= cfg_len + kRemapBlock) { + uint8_t *p = buffer + cfg_len; + p[0] = 'R'; + p[1] = 'M'; + p[2] = kRemapProtoVer; + const uint16_t rev = remap_revision(); + p[3] = (uint8_t)(rev & 0xFF); + p[4] = (uint8_t)((rev >> 8) & 0xFF); + remap_get(p + 5); + return cfg_len + kRemapBlock; + } return len; } if (report_id == 0xf8) { @@ -268,4 +292,25 @@ void pico_cmd_set(uint8_t report_id, uint8_t const *buffer, uint16_t bufsize) { sleep_ms(150); tud_connect(); } + // 0x10 set button-remap table (OLED Edition). Hardened framing so a stray + // write to 0xF6 can't corrupt the map: magic 'R''M' + protocol version gate + // before remap_set() (which itself validates each entry <16 or 0xFF=off). + // [0] 0x10 func-id + // [1] 'R' + // [2] 'M' + // [3] protocol version (must == kRemapProtoVer) + // [4..19] 16-byte remap table + if (buffer[0] == 0x10) { + constexpr uint16_t kNeed = 4 + kRemapCount; + if (bufsize < kNeed) { + printf("[CMD] 0x10 remap-set too short (%u<%u)\n", bufsize, kNeed); + return; + } + if (buffer[1] != 'R' || buffer[2] != 'M' || buffer[3] != kRemapProtoVer) { + printf("[CMD] 0x10 remap-set bad magic/version\n"); + return; + } + if (remap_set(buffer + 4)) printf("[CMD] remap set ok (rev=%u)\n", remap_revision()); + else printf("[CMD] remap set rejected (invalid table)\n"); + } } diff --git a/src/main.cpp b/src/main.cpp index 5a3f33a..c0af18f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,7 @@ #include "battery_led.h" #endif #include "oled.h" +#include "remap.h" // Pico SDK speciifically for waiting on conditions #include "pico/critical_section.h" @@ -118,7 +119,12 @@ void interrupt_loop() { // TODO: Refactor for better code reuse if (get_config().polling_rate_mode != 2) { - if (!tud_hid_report(0x01, interrupt_in_data, 63)) { + // Remap acts on the OUTGOING copy only — interrupt_in_data stays raw so + // the reboot combo above and every OLED screen keep seeing physical input. + uint8_t out[63]; + memcpy(out, interrupt_in_data, 63); + remap_apply(out); + if (!tud_hid_report(0x01, out, 63)) { printf("[USBHID] tud_hid_report error\n"); } return; @@ -137,6 +143,9 @@ void interrupt_loop() { } critical_section_exit(&report_cs); + // Remap the snapshot, not interrupt_in_data (outgoing copy only — see above). + if (should_send) remap_apply(safe_report); + // Only send to TinyUSB if we actually grabbed fresh data if (should_send) { if (!tud_hid_report(0x01, safe_report, 63)) { @@ -377,6 +386,7 @@ int main() { critical_section_init(&report_cs); config_load(); + remap_load(); bt_init(); bt_register_data_callback(on_bt_data); diff --git a/src/remap.cpp b/src/remap.cpp new file mode 100644 index 0000000..9b71d15 --- /dev/null +++ b/src/remap.cpp @@ -0,0 +1,197 @@ +// Button remapping. See remap.h. Flash-sector pattern mirrors slots.cpp; +// the apply logic + button set are ported from SundayMoments/DS5_Bridge. + +#include "remap.h" + +#include +#include +#include + +#include "hardware/flash.h" +#include "hardware/sync.h" + +namespace { + +// Source/target button indices. Order is arbitrary but MUST match the web +// editor's expectation (DS5Dongle-OLED-Config-Web src/protocol/remap.ts). +enum RemapButton : uint8_t { + RemapL2, RemapL1, RemapCreate, RemapDpadUp, RemapDpadLeft, RemapDpadDown, + RemapDpadRight, RemapL3, RemapR2, RemapR1, RemapOptions, RemapTriangle, + RemapCircle, RemapCross, RemapSquare, RemapR3, RemapButtonCount, +}; +static_assert(RemapButtonCount == kRemapCount, "kRemapCount must match RemapButton"); + +// interrupt_in_data[8]: shoulders / sticks / system bits. +constexpr uint8_t kL1Bit = 0x01, kR1Bit = 0x02, kL2Bit = 0x04, kR2Bit = 0x08, + kCreateBit = 0x10, kOptionsBit = 0x20, kL3Bit = 0x40, kR3Bit = 0x80; +// interrupt_in_data[7]: D-pad hat (low nibble) + face buttons (high nibble). +constexpr uint8_t kSquareBit = 0x10, kCrossBit = 0x20, kCircleBit = 0x40, + kTriangleBit = 0x80, kDpadMask = 0x0F; +// D-pad hat values. +constexpr uint8_t kUp = 0, kUpRight = 1, kRight = 2, kDownRight = 3, kDown = 4, + kDownLeft = 5, kLeft = 6, kUpLeft = 7, kNeutral = 8; + +constexpr uint32_t REMAP_MAGIC = 0x44533503u; // "DS5\x03" +constexpr uint32_t REMAP_FLASH_OFFSET = PICO_FLASH_SIZE_BYTES - 3u * FLASH_SECTOR_SIZE; + +struct __attribute__((packed)) RemapData { + uint32_t magic; + uint8_t table[kRemapCount]; +}; +static_assert(sizeof(RemapData) <= FLASH_PAGE_SIZE); +static_assert(REMAP_FLASH_OFFSET % FLASH_SECTOR_SIZE == 0); + +RemapData g_remap{}; +bool g_active = false; // false = identity → remap_apply() fast-returns +uint16_t g_revision = 0; + +const RemapData *flash_remap() { + return reinterpret_cast(XIP_BASE + REMAP_FLASH_OFFSET); +} + +void set_identity() { + for (int i = 0; i < kRemapCount; i++) g_remap.table[i] = (uint8_t) i; +} + +void recompute_active() { + g_active = false; + for (int i = 0; i < kRemapCount; i++) { + if (g_remap.table[i] != i) { g_active = true; return; } + } +} + +bool valid_table(const uint8_t *t) { + for (int i = 0; i < kRemapCount; i++) { + if (t[i] >= kRemapCount && t[i] != 0xFF) return false; // <16 or disabled + } + return true; +} + +bool save_to_flash() { + alignas(4) uint8_t page[FLASH_PAGE_SIZE]; + memset(page, 0xff, sizeof(page)); + memcpy(page, &g_remap, sizeof(g_remap)); + + const uint32_t interrupts = save_and_disable_interrupts(); + flash_range_erase(REMAP_FLASH_OFFSET, FLASH_SECTOR_SIZE); + flash_range_program(REMAP_FLASH_OFFSET, page, sizeof(page)); + restore_interrupts(interrupts); + + RemapData verify{}; + memcpy(&verify, flash_remap(), sizeof(verify)); + if (memcmp(&verify, &g_remap, sizeof(g_remap)) == 0) { + printf("[Remap] flash write verified\n"); + return true; + } + printf("[Remap] flash write VERIFY FAILED\n"); + return false; +} + +bool dpad_has(uint8_t dir, RemapButton b) { + switch (b) { + case RemapDpadUp: return dir == kUp || dir == kUpRight || dir == kUpLeft; + case RemapDpadRight: return dir == kRight || dir == kUpRight || dir == kDownRight; + case RemapDpadDown: return dir == kDown || dir == kDownRight || dir == kDownLeft; + case RemapDpadLeft: return dir == kLeft || dir == kUpLeft || dir == kDownLeft; + default: return false; + } +} + +uint8_t dpad_from(bool up, bool right, bool down, bool left) { + if (up && right && !down && !left) return kUpRight; + if (right && down && !up && !left) return kDownRight; + if (down && left && !up && !right) return kDownLeft; + if (left && up && !right && !down) return kUpLeft; + if (up && !down) return kUp; + if (right && !left) return kRight; + if (down && !up) return kDown; + if (left && !right) return kLeft; + return kNeutral; +} + +} // namespace + +void remap_load() { + memcpy(&g_remap, flash_remap(), sizeof(g_remap)); + if (g_remap.magic != REMAP_MAGIC || !valid_table(g_remap.table)) { + printf("[Remap] flash sector empty/invalid, defaulting to identity\n"); + g_remap.magic = REMAP_MAGIC; + set_identity(); + save_to_flash(); + } + recompute_active(); + printf("[Remap] loaded (active=%d)\n", g_active); +} + +void remap_get(uint8_t out[kRemapCount]) { memcpy(out, g_remap.table, kRemapCount); } + +uint16_t remap_revision() { return g_revision; } + +bool remap_set(const uint8_t *table) { + if (!valid_table(table)) return false; + memcpy(g_remap.table, table, kRemapCount); + g_remap.magic = REMAP_MAGIC; + recompute_active(); + g_revision++; + return save_to_flash(); +} + +void remap_apply(uint8_t *report) { + if (!g_active) return; // identity → no-op (hot-path fast return) + + bool src[kRemapCount]{}; + uint8_t src_analog[kRemapCount]{}; + const uint8_t dir = report[7] & kDpadMask; + + src[RemapL2] = (report[8] & kL2Bit) != 0; + src[RemapL1] = (report[8] & kL1Bit) != 0; + src[RemapCreate] = (report[8] & kCreateBit) != 0; + src[RemapDpadUp] = dpad_has(dir, RemapDpadUp); + src[RemapDpadLeft] = dpad_has(dir, RemapDpadLeft); + src[RemapDpadDown] = dpad_has(dir, RemapDpadDown); + src[RemapDpadRight] = dpad_has(dir, RemapDpadRight); + src[RemapL3] = (report[8] & kL3Bit) != 0; + src[RemapR2] = (report[8] & kR2Bit) != 0; + src[RemapR1] = (report[8] & kR1Bit) != 0; + src[RemapOptions] = (report[8] & kOptionsBit) != 0; + src[RemapTriangle] = (report[7] & kTriangleBit) != 0; + src[RemapCircle] = (report[7] & kCircleBit) != 0; + src[RemapCross] = (report[7] & kCrossBit) != 0; + src[RemapSquare] = (report[7] & kSquareBit) != 0; + src[RemapR3] = (report[8] & kR3Bit) != 0; + + for (int i = 0; i < kRemapCount; i++) src_analog[i] = src[i] ? 0xFF : 0; + src_analog[RemapL2] = report[4]; // L2 analog + src_analog[RemapR2] = report[5]; // R2 analog + + bool tgt[kRemapCount]{}; + uint8_t tgt_analog[kRemapCount]{}; + for (int s = 0; s < kRemapCount; s++) { + const uint8_t t = g_remap.table[s]; + if (t >= kRemapCount) continue; // 0xFF = disabled (source produces nothing) + if (src[s]) tgt[t] = true; + tgt_analog[t] = std::max(tgt_analog[t], src_analog[s]); // OR digital / max analog + } + + report[4] = tgt_analog[RemapL2]; + report[5] = tgt_analog[RemapR2]; + + // Byte 7 is entirely D-pad + face (all 8 bits) — rebuild it. + report[7] = dpad_from(tgt[RemapDpadUp], tgt[RemapDpadRight], + tgt[RemapDpadDown], tgt[RemapDpadLeft]); + if (tgt[RemapSquare]) report[7] |= kSquareBit; + if (tgt[RemapCross]) report[7] |= kCrossBit; + if (tgt[RemapCircle]) report[7] |= kCircleBit; + if (tgt[RemapTriangle]) report[7] |= kTriangleBit; + + // Byte 8 is entirely shoulders/sticks/Create/Options (all 8 bits) — rebuild it. + report[8] = 0; + if (tgt[RemapL1]) report[8] |= kL1Bit; + if (tgt[RemapR1]) report[8] |= kR1Bit; + if (tgt[RemapL2]) report[8] |= kL2Bit; + if (tgt[RemapR2]) report[8] |= kR2Bit; + if (tgt[RemapCreate]) report[8] |= kCreateBit; + if (tgt[RemapOptions]) report[8] |= kOptionsBit; + if (tgt[RemapL3]) report[8] |= kL3Bit; + if (tgt[RemapR3]) report[8] |= kR3Bit; +} diff --git a/src/remap.h b/src/remap.h new file mode 100644 index 0000000..9b4218a --- /dev/null +++ b/src/remap.h @@ -0,0 +1,43 @@ +// +// Button remapping — a 16-entry table persisted in its own flash sector, +// applied to the OUTGOING host HID report only (never the raw interrupt_in_data +// the OLED / reboot-combo logic reads). Edited from the web config tool over the +// already-declared 0xF6/0xF7 vendor reports. Apply logic + button set ported +// from SundayMoments/DS5_Bridge (credit). +// + +#ifndef DS5_BRIDGE_REMAP_H +#define DS5_BRIDGE_REMAP_H + +#include + +// Number of remappable buttons (see RemapButton in remap.cpp). The table maps +// source index -> target index; 0xFF means "disabled" (source does nothing). +constexpr int kRemapCount = 16; + +// Wire-protocol version for the remap get/set framing carried over the existing +// 0xF6/0xF7 vendor reports (see cmd.cpp). Bump only on incompatible layout +// changes so the web tool can refuse a mismatched firmware. +constexpr uint8_t kRemapProtoVer = 1; + +// Load the table from its dedicated flash sector. Call once at boot, after +// config_load(). A fresh/invalid sector defaults to identity (no remap). +void remap_load(); + +// Remap a 63-byte DS5 input-report COPY in place (buttons live in report[4,5,7,8]). +// No-op fast path when the table is identity. Must only ever touch the outgoing +// host report, not the raw interrupt_in_data. +void remap_apply(uint8_t *report); + +// Validate + store + persist a new 16-entry table (each entry < 16, or 0xFF = +// disabled). Bumps the revision on success. Returns false if the table is invalid. +bool remap_set(const uint8_t *table); + +// Copy the current 16-entry table out. +void remap_get(uint8_t out[kRemapCount]); + +// Monotonic counter bumped on each successful remap_set — the web polls it to +// confirm a write landed. Runtime only (not persisted). +uint16_t remap_revision(); + +#endif // DS5_BRIDGE_REMAP_H