Jari před 2 měsíci
rodič
revize
08a9768fd5

+ 14 - 223
OpenPuck/gamepad_util.cpp

@@ -1,173 +1,20 @@
+// gamepad_util.cpp -- only the CONFIG-COUPLED report helpers live here; the pure
+// packers (stick/touch/hat/face/shoulder math) are in the shared source of truth
+// src/common/gamepad_util.c, linked by both OpenPuck and PicoPuck. These two read
+// OpenPuck's config globals (g_abSwap / g_back / g_qamMap), so they stay local.
 #include "gamepad_util.h"
 #include "triton.h"
 #include "config.h"
 
-void psNeutralCalib(uint8_t *buf)
-{
-	// Payload offsets = kernel buf[] index minus 1. buf[0..5] gyro bias stays zero (caller memset). Symmetric
-	// ranges -> non-zero divisors on the host.
-	le16(buf + 6, 2844);
-	le16(buf + 8, -2844); // gyro pitch +/-
-	le16(buf + 10, 2844);
-	le16(buf + 12, -2844); // gyro yaw +/-
-	le16(buf + 14, 2844);
-	le16(buf + 16, -2844); // gyro roll +/-
-	le16(buf + 18, 2844);
-	le16(buf + 20, 2844); // gyro speed +/- (sum != 0)
-	le16(buf + 22, 8192);
-	le16(buf + 24, -8192); // accel X +/-
-	le16(buf + 26, 8192);
-	le16(buf + 28, -8192); // accel Y +/-
-	le16(buf + 30, 8192);
-	le16(buf + 32, -8192); // accel Z +/-
-}
-
-uint8_t swStick(int16_t v, bool invert)
-{
-	int32_t a = 0x80 + (invert ? -((int32_t)v >> 8) : ((int32_t)v >> 8));
-	if (a < 0)
-		a = 0;
-	if (a > 255)
-		a = 255;
-	return (uint8_t)a;
-}
-
-// Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
-uint16_t padNormU16(int16_t v, uint16_t maxv)
-{
-	int32_t t = ((int32_t)v + 32768);
-	if (t < 0)
-		t = 0;
-	if (t > 65535)
-		t = 65535;
-	return (uint16_t)((t * (int32_t)maxv) / 65535);
-}
-uint16_t touchHalfX(int16_t v, bool rightHalf)
-{
-	uint16_t halfMax = TOUCH_PAD_W / 2 - 1;
-	uint16_t x = padNormU16(v, halfMax);
-	return rightHalf ? (uint16_t)(TOUCH_PAD_W / 2 + x) : x;
-}
-uint16_t touchYInv(int16_t v, uint16_t height)
-{
-	uint16_t maxy = height - 1;
-	return (uint16_t)(maxy - padNormU16(v, maxy));
-}
-void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
-		    uint16_t y)
-{
-	uint8_t *f = base + finger * 4;
-	if (!touch) {
-		f[0] = 0x80;
-		f[1] = 0;
-		f[2] = 0;
-		f[3] = 0;
-		return;
-	}
-	f[0] = (uint8_t)(finger & 0x7F);
-	f[1] = (uint8_t)(x & 0xFF);
-	f[2] = (uint8_t)(((x >> 8) & 0x0F) | ((y & 0x0F) << 4));
-	f[3] = (uint8_t)((y >> 4) & 0xFF);
-}
-void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
-		   uint16_t ly, uint16_t rx, uint16_t ry)
-{
-	touchPackPoint(pts, 0, false, 0, 0);
-	touchPackPoint(pts, 1, false, 0, 0);
-	if (lTouch && rTouch) {
-		touchPackPoint(pts, 0, true, lx, ly);
-		touchPackPoint(pts, 1, true, rx, ry);
-	} else if (lTouch) {
-		touchPackPoint(pts, 0, true, lx, ly);
-	} else if (rTouch) {
-		touchPackPoint(pts, 0, true, rx, ry);
-	}
-}
-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)
+// PS face nibble with the A/B + X/Y swap driven by the global Nintendo-layout
+// flag (the shared ps_face_nibble takes the swap as an explicit parameter).
+uint8_t psFaceNibble(uint32_t b)
 {
-	bool lt = (b & TB_LPADT) || (b & TB_LPADC),
-	     rt = (b & TB_RPADT) || (b & TB_RPADC);
-	*lx = touchHalfX(lpx, false);
-	*ly = touchYInv(lpy, touchH);
-	*rx = touchHalfX(rpx, true);
-	*ry = touchYInv(rpy, touchH);
-	if (lt && !(b & TB_LPADT)) {
-		*lx = TOUCH_PAD_W / 4;
-		*ly = touchH / 2;
-	}
-	if (rt && !(b & TB_RPADT)) {
-		*rx = TOUCH_PAD_W / 4 * 3;
-		*ry = touchH / 2;
-	}
+	return ps_face_nibble(b, g_abSwap);
 }
 
-// Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
-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_QAM;
-		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;
-	case 16:
-		*b |= TB_TOUCH;
-		break;
-	case 17:
-		*b |= TB_MUTE;
-		break;
-	case 19:
-		*b |= TB_L2; // left trigger (L2)
-		break;
-	case 20:
-		*b |= TB_R2; // right trigger (R2)
-		break;
-	default:
-		break;
-	}
-}
+// Apply the QAM remap, the all-four-back-paddles chord guard, and the four
+// back-paddle remaps to a raw Triton button field. Uses the shared code->TB_ map.
 uint32_t psButtonsFromSteam(uint32_t raw)
 {
 	uint32_t b = raw;
@@ -178,68 +25,12 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 	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]);
+		b |= tritonFromCode(g_back[0]);
 	if (b & TB_R4)
-		psOrBackCode(&b, g_back[1]);
+		b |= tritonFromCode(g_back[1]);
 	if (b & TB_L5)
-		psOrBackCode(&b, g_back[2]);
+		b |= tritonFromCode(g_back[2]);
 	if (b & TB_R5)
-		psOrBackCode(&b, g_back[3]);
+		b |= tritonFromCode(g_back[3]);
 	return b;
 }
-// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start). The analog trigger values come from
-// the per-slot `g_in[slot]` (pass lt/rt explicitly so a single shoulder byte never leaks across slots).
-uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
-{
-	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
-	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
-	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
-	       ((b & TB_MENU) ? 0x10 : 0) |
-	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
-	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
-}
-uint8_t psHatNibble(uint32_t b)
-{
-	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
-	if (u && r)
-		return 1;
-	if (r && d)
-		return 3;
-	if (d && l)
-		return 5;
-	if (l && u)
-		return 7;
-	if (u)
-		return 0;
-	if (r)
-		return 2;
-	if (d)
-		return 4;
-	if (l)
-		return 6;
-	return 8;
-}
-uint8_t psFaceNibble(uint32_t b)
-{
-	uint8_t f = 0;
-	if (g_abSwap) {
-		if (b & TB_A)
-			f |= 0x40;
-		if (b & TB_B)
-			f |= 0x20;
-		if (b & TB_X)
-			f |= 0x80;
-		if (b & TB_Y)
-			f |= 0x10;
-	} else {
-		if (b & TB_A)
-			f |= 0x20;
-		if (b & TB_B)
-			f |= 0x40;
-		if (b & TB_X)
-			f |= 0x10;
-		if (b & TB_Y)
-			f |= 0x80;
-	}
-	return f;
-}

+ 39 - 87
OpenPuck/gamepad_util.h

@@ -1,97 +1,49 @@
-// gamepad_util.h -- shared helpers for building host USB gamepad reports from g_in.
-//
-// These are reused across the DualSense / DS4 / Switch personalities: analog-stick rescaling, Steam-trackpad
-// -> absolute-touch mapping, and the PlayStation-layout button/hat/face/shoulder packers. Per-target button
-// code maps (codeToXB / codeToSwitch / codeToJc) live with their own controllers; only the cross-mode pieces
-// are here.
+// gamepad_util.h -- OpenPuck's thin adapter over the SHARED report-building math
+// (src/common/gamepad_util.{h,c}, linked by both OpenPuck and PicoPuck). The pure
+// packers live in the shared file under snake_case names; the camelCase wrappers
+// here keep OpenPuck's existing call sites unchanged, and the config-coupled
+// remapping (psButtonsFromSteam / psFaceNibble, which read g_back/g_qamMap/
+// g_abSwap) stays OpenPuck-local in gamepad_util.cpp.
 #pragma once
 #include <stdint.h>
 #include "triton.h"
+#include "src/common/gamepad_util.h"
 
-// int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
-uint8_t swStick(int16_t v, bool invert);
-
-// Write a signed 16-bit value little-endian.
-static inline void le16(uint8_t *p, int16_t v)
+// camelCase compatibility wrappers -> shared snake_case implementations.
+static inline uint8_t swStick(int16_t v, bool invert)
 {
-	p[0] = (uint8_t)(v & 0xFF);
-	p[1] = (uint8_t)((v >> 8) & 0xFF);
+	return sw_stick(v, invert);
+}
+static inline void psNeutralCalib(uint8_t *buf)
+{
+	ps_neutral_calib(buf);
+}
+static inline uint8_t psHatNibble(uint32_t b)
+{
+	return ps_hat_nibble(b);
+}
+static inline uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
+{
+	return ps_shoulders_byte(b, lt, rt);
+}
+static inline void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch,
+				 uint16_t lx, uint16_t ly, uint16_t rx,
+				 uint16_t ry)
+{
+	touch_pack_pads(pts, lTouch, rTouch, lx, ly, rx, ry);
+}
+static inline 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)
+{
+	steam_pads_to_touch(b, touchH, lpx, lpy, rpx, rpy, lx, ly, rx, ry);
 }
-
-// Fill a DS4/DualSense motion-calibration feature report PAYLOAD. The TinyUSB GET_REPORT path already wrote the
-// report id and hands us the buffer PAST it, so our offsets = the kernel's buf[] index minus one (bias at 0..5,
-// stays zero from the caller's memset). Zero bias + symmetric non-zero ranges so hid-playstation's/hid-sony's
-// divisor (|plus-bias|+|minus-bias|, and gyro speed_plus+speed_minus) is never zero. Shared by report 0x02 (DS4)
-// and 0x05 (DualSense) -- identical layout; the caller returns size-1 and leaves trailing bytes zero.
-void psNeutralCalib(uint8_t *buf);
-
-// Steam trackpad s16 coords -> absolute touch surface. TOUCH_PAD_W is split into left/right halves so both
-// pads can co-exist as two contacts on a single DualSense/DS4 touchpad.
-#define TOUCH_PAD_W 1920u
-uint16_t padNormU16(int16_t v, uint16_t maxv);
-uint16_t touchHalfX(int16_t v, bool rightHalf);
-uint16_t touchYInv(int16_t v, uint16_t height);
-void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
-		    uint16_t y);
-// DualSense / DS4: many hosts only honor contact slot 0 -- put a lone pad in slot 0, both pads in 0+1.
-void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
-		   uint16_t ly, uint16_t rx, uint16_t ry);
-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_QAM;
-	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;
-	case 19:
-		return TB_L2; // left trigger (LT / L2 / ZL)
-	case 20:
-		return TB_R2; // right trigger (RT / R2 / ZR)
-	default:
-		return 0;
-	}
+	return triton_from_code(c);
 }
 
-// PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
-uint32_t psButtonsFromSteam(
-	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
-// L1..R3 + Create/Options byte. lt/rt are the per-slot analog trigger values so the digital ZL/ZR trip
-// doesn't leak across slots when several slots are active.
-uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt);
-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)
+// Config-coupled (defined in gamepad_util.cpp; read g_abSwap / g_back / g_qamMap):
+uint8_t psFaceNibble(uint32_t b); // PS face nibble with A/B + X/Y swap
+uint32_t psButtonsFromSteam(uint32_t); // back-paddle + chord-guard + QAM remap

+ 9 - 188
OpenPuck/mode_switch_pro.cpp

@@ -14,6 +14,7 @@
 #include <string.h>
 #include <math.h>
 #include "src/common/hid_reports.h"
+#include "src/common/hd_rumble.h"
 using namespace Adafruit_LittleFS_Namespace;
 
 SwitchProController g_switchPro;
@@ -165,194 +166,14 @@ static inline uint8_t jcBondOf(uint8_t usbSlot)
 	int b = (usbSlot < NSLOT) ? g_usbToBond[usbSlot] : -1;
 	return (b >= 0) ? (uint8_t)b : usbSlot;
 }
-// --- Switch HD-rumble amplitude decoder.
-//
-// When we emulate a Pro Controller, the console streams us its HD-rumble output
-// every frame. A genuine controller drives dual-band linear actuators from it;
-// OpenPuck drives the SC2's single motor, so all we need is a scalar amplitude.
-// This decodes the on-wire rumble format down to that amplitude.
-//
-// Format (from the public Switch controller protocol, cross-checked against
-// SDL's zlib-licensed hidapi_switch rumble encoder and community RE notes):
-// each motor carries 4 bytes = one little-endian 32-bit word whose top two bits
-// pick how many amplitude/frequency updates are packed and at what width:
-//   mode 0 : no change  -- hold the running amplitude
-//   mode 1 : a single 5-bit or 7-bit update, or a 7-bit + two 5-bit burst
-//   mode 2 : two 5-bit updates, or a 7-bit + 5-bit followed by a 5-bit update
-//   mode 3 : three 5-bit updates
-// A 7-bit field is an ABSOLUTE amplitude on the documented log2 curve. A 5-bit
-// field is a compact command that either substitutes a preset level or nudges
-// the amplitude by a small step -- so the decoder carries per-motor state across
-// frames. Frequency fields exist but do nothing for an ERM, so we step past them
-// and keep only amplitude.
-//
-// The naive "read fixed bytes as amplitude" decode we shipped before ignored the
-// mode bits, so any game using the packed modes (Fire Emblem: Three Houses,
-// Crash Bandicoot, ...) decoded to spurious nonzero amplitude -> random idle/menu
-// buzzing. Handling every mode is what fixes that.
-//
-// Amplitude is carried in log2-linear 1/32 fixed-point over [-8.0, 0.0] -> the
-// integer range [-256, 0], and mapped to a 16-bit motor level via a boot-built
-// exp2 table so no floating point runs in the USB ISR. The curve constants below
-// are protocol facts (any correct decoder lands on the same numbers).
-enum { HDR_AMP_MIN = -256, HDR_AMP_OFF = -256 }; // -8.0 log2 units == silent
-
-// Absolute 7-bit amplitude code -> 1/32 log2 units. The documented curve is
-// piecewise linear with progressively finer steps toward full scale (slopes
-// 1/4, 1/16, 1/32); code 0 is silence.
-static inline int16_t hdrAmp7(uint8_t code)
-{
-	if (code == 0)
-		return HDR_AMP_MIN;
-	if (code < 16)
-		return (int16_t)(8 * (int)code - 248); // slope 1/4
-	if (code < 32)
-		return (int16_t)(2 * (int)code - 158); // slope 1/16
-	return (int16_t)((int)code - 127); // slope 1/32
-}
-// Apply a compact 5-bit command to the running amplitude (1/32 log2 units):
-//   0        -> silence
-//   1..11    -> substitute an absolute preset: 0, -0.5, -1.0, ... -5.0
-//   17..22   -> step up   (+0.125 for 17-19, +0.03125 for 20-22)
-//   26..31   -> step down (-0.03125 for 26-28, -0.125 for 29-31)
-//   other    -> amplitude unchanged (the code only carries a frequency command)
-static inline int16_t hdrAmp5(uint8_t code, int16_t cur)
-{
-	if (code == 0)
-		return HDR_AMP_OFF;
-	if (code <= 11)
-		return (int16_t)(-16 * (int)(code - 1)); // presets 0..-5.0
-	int step = 0;
-	if (code >= 17 && code <= 19)
-		step = 4;
-	else if (code >= 20 && code <= 22)
-		step = 1;
-	else if (code >= 26 && code <= 28)
-		step = -1;
-	else if (code >= 29 && code <= 31)
-		step = -4;
-	int v = (int)cur + step;
-	return v < HDR_AMP_MIN ? HDR_AMP_MIN : (v > 0 ? 0 : (int16_t)v);
-}
-// exp2(units/32) scaled to a 16-bit motor level, built once at boot. The two
-// lowest steps are treated as silent (the curve floors out there), matching how
-// the neutral/idle frame -- which decodes to minimum amplitude -- reads as off.
-static uint16_t g_hdrLevel[257];
-static void hdrBuildLevels()
-{
-	for (int u = HDR_AMP_MIN; u <= 0; u++) {
-		float lin = (float)u / 32.0f;
-		float amp = (lin >= -7.9375f) ? exp2f(lin) : 0.0f;
-		if (amp > 1.0f)
-			amp = 1.0f;
-		uint32_t v = (uint32_t)(amp * 65535.0f + 0.5f);
-		g_hdrLevel[u - HDR_AMP_MIN] = (v > 0xFFFF) ? 0xFFFF :
-							     (uint16_t)v;
-	}
-}
-// Per-slot, per-motor (0 = left, 1 = right) running band amplitudes. The packed
-// 5-bit commands are relative, so this state must persist between frames.
-struct HdrBands {
-	int16_t lo; // low-band amplitude, 1/32 log2 units
-	int16_t hi; // high-band amplitude, 1/32 log2 units
-};
-static HdrBands g_hdrState[NSLOT][2];
+// Switch HD-rumble amplitude decoding lives in the shared module
+// (common/hd_rumble.c), byte-for-byte identical with PicoPuck. Per-slot,
+// per-motor band state persists across frames; hdrReset() silences a slot.
+static hdr_band_t g_hdrState[NSLOT][2];
 static inline void hdrReset(uint8_t slot)
 {
-	g_hdrState[slot][0].lo = g_hdrState[slot][0].hi = HDR_AMP_OFF;
-	g_hdrState[slot][1].lo = g_hdrState[slot][1].hi = HDR_AMP_OFF;
-}
-// Pull a bit-field out of the 32-bit rumble word.
-static inline uint8_t hdrField(uint32_t w, uint8_t shift, uint8_t width)
-{
-	return (uint8_t)((w >> shift) & ((1u << width) - 1u));
-}
-// Decode one motor's 4 rumble bytes, advancing its state, and return the PEAK
-// motor level over the frame's updates (max across both bands and all samples).
-// Peak rather than final-sample keeps short pulses that a multi-update frame
-// packs together, while every idle/neutral frame still resolves to 0.
-static uint16_t hdrDecode(uint8_t slot, uint8_t motor, const uint8_t b[4])
-{
-	HdrBands &s = g_hdrState[slot][motor];
-	uint32_t w = (uint32_t)b[0] | ((uint32_t)b[1] << 8) |
-		     ((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
-	uint16_t peak = 0;
-#define HDR_SAMPLE()                                          \
-	do {                                                  \
-		uint16_t la = g_hdrLevel[s.lo - HDR_AMP_MIN]; \
-		uint16_t ha = g_hdrLevel[s.hi - HDR_AMP_MIN]; \
-		uint16_t lv = la > ha ? la : ha;              \
-		if (lv > peak)                                \
-			peak = lv;                            \
-	} while (0)
-
-	switch (hdrField(w, 30, 2)) {
-	case 0: // hold
-		HDR_SAMPLE();
-		break;
-	case 1:
-		if ((w & 0xFFFFF) == 0) { // single 5-bit update
-			s.lo = hdrAmp5(hdrField(w, 25, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 20, 5), s.hi);
-			HDR_SAMPLE();
-		} else if ((w & 0x3) == 0) { // single 7-bit absolute
-			s.lo = hdrAmp7(hdrField(w, 23, 7));
-			s.hi = hdrAmp7(hdrField(w, 9, 7));
-			HDR_SAMPLE();
-		} else { // 7-bit for one band + two 5-bit updates
-			bool wantHi = (w & 1) != 0;
-			bool isFreq = ((w >> 2) & 1) != 0;
-			if (!isFreq) { // else the 7-bit is a frequency: ignore
-				if (wantHi)
-					s.hi = hdrAmp7(hdrField(w, 23, 7));
-				else
-					s.lo = hdrAmp7(hdrField(w, 23, 7));
-			}
-			HDR_SAMPLE();
-			s.lo = hdrAmp5(hdrField(w, 18, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 13, 5), s.hi);
-			HDR_SAMPLE();
-			s.lo = hdrAmp5(hdrField(w, 8, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 3, 5), s.hi);
-			HDR_SAMPLE();
-		}
-		break;
-	case 2:
-		if ((w & 0x3FF) == 0) { // two 5-bit updates
-			s.lo = hdrAmp5(hdrField(w, 25, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 20, 5), s.hi);
-			HDR_SAMPLE();
-			s.lo = hdrAmp5(hdrField(w, 15, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 10, 5), s.hi);
-			HDR_SAMPLE();
-		} else { // 7-bit + 5-bit, then a 5-bit update
-			if (w & 1) {
-				s.hi = hdrAmp7(hdrField(w, 23, 7));
-				s.lo = hdrAmp5(hdrField(w, 18, 5), s.lo);
-			} else {
-				s.lo = hdrAmp7(hdrField(w, 23, 7));
-				s.hi = hdrAmp5(hdrField(w, 18, 5), s.hi);
-			}
-			HDR_SAMPLE();
-			s.lo = hdrAmp5(hdrField(w, 13, 5), s.lo);
-			s.hi = hdrAmp5(hdrField(w, 8, 5), s.hi);
-			HDR_SAMPLE();
-		}
-		break;
-	case 3: // three 5-bit updates
-		s.lo = hdrAmp5(hdrField(w, 25, 5), s.lo);
-		s.hi = hdrAmp5(hdrField(w, 20, 5), s.hi);
-		HDR_SAMPLE();
-		s.lo = hdrAmp5(hdrField(w, 15, 5), s.lo);
-		s.hi = hdrAmp5(hdrField(w, 10, 5), s.hi);
-		HDR_SAMPLE();
-		s.lo = hdrAmp5(hdrField(w, 5, 5), s.lo);
-		s.hi = hdrAmp5(hdrField(w, 0, 5), s.hi);
-		HDR_SAMPLE();
-		break;
-	}
-#undef HDR_SAMPLE
-	return peak;
+	hdr_reset(&g_hdrState[slot][0]);
+	hdr_reset(&g_hdrState[slot][1]);
 }
 // Per-slot: each Pro Controller has its own rumble stream, so the "last" relay tracking must be per-slot.
 static uint16_t g_jcLastLo[NSLOT] = { 0 };
@@ -361,7 +182,8 @@ static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 {
 	if (pn < 9)
 		return; // [timer][left rumble x4][right rumble x4]
-	uint16_t lo = hdrDecode(slot, 0, p + 1), hi = hdrDecode(slot, 1, p + 5);
+	uint16_t lo = hdr_decode(&g_hdrState[slot][0], p + 1),
+		 hi = hdr_decode(&g_hdrState[slot][1], p + 5);
 	// only relay on change: the Switch streams rumble every frame; re-sending
 	// unchanged values would flood the RF relay and loop the motor
 	if (lo == g_jcLastLo[slot] && hi == g_jcLastHi[slot])
@@ -891,7 +713,6 @@ void SwitchProController::usbIdentity()
 void SwitchProController::beginPool()
 {
 	jcBuildStickCal();
-	hdrBuildLevels();
 	for (uint8_t s = 0; s < NSLOT; s++)
 		hdrReset(s);
 	loadUserCal();

+ 89 - 57
PicoPuck/src/puck/gamepad_util.c → OpenPuck/src/common/gamepad_util.c

@@ -1,17 +1,7 @@
-// gamepad_util.c — shared report-building helpers (see gamepad_util.h).
-//
-// Ported from OpenPuck/gamepad_util.cpp. PicoPuck omits the configurable
-// back-paddle / QAM / A-B-swap remapping for now (chord.c already masks the
-// switch chord out of g_in), so these use the base PlayStation layout.
+// gamepad_util.c — see gamepad_util.h.
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
-#include "puck/gamepad_util.h"
-
-static inline void le16(uint8_t *p, int16_t v)
-{
-	p[0] = (uint8_t)(v & 0xFF);
-	p[1] = (uint8_t)((v >> 8) & 0xFF);
-}
+#include "gamepad_util.h"
 
 uint8_t sw_stick(int16_t v, bool invert)
 {
@@ -25,53 +15,32 @@ uint8_t sw_stick(int16_t v, bool invert)
 
 void ps_neutral_calib(uint8_t *buf)
 {
-	le16(buf + 6, 2844);  le16(buf + 8, -2844);   // gyro pitch +/-
-	le16(buf + 10, 2844); le16(buf + 12, -2844);  // gyro yaw +/-
-	le16(buf + 14, 2844); le16(buf + 16, -2844);  // gyro roll +/-
-	le16(buf + 18, 2844); le16(buf + 20, 2844);   // gyro speed +/-
-	le16(buf + 22, 8192); le16(buf + 24, -8192);  // accel X +/-
-	le16(buf + 26, 8192); le16(buf + 28, -8192);  // accel Y +/-
-	le16(buf + 30, 8192); le16(buf + 32, -8192);  // accel Z +/-
-}
-
-uint8_t ps_hat_nibble(uint32_t b)
-{
-	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
-	if (u && r) return 1;
-	if (r && d) return 3;
-	if (d && l) return 5;
-	if (l && u) return 7;
-	if (u) return 0;
-	if (r) return 2;
-	if (d) return 4;
-	if (l) return 6;
-	return 8;
-}
-
-uint8_t ps_face_nibble(uint32_t b)
-{
-	uint8_t f = 0;
-	if (b & TB_A) f |= 0x20;  // cross
-	if (b & TB_B) f |= 0x40;  // circle
-	if (b & TB_X) f |= 0x10;  // square
-	if (b & TB_Y) f |= 0x80;  // triangle
-	return f;
-}
-
-uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt)
-{
-	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
-	       ((lt > SW_TRIG_ON || (b & TB_L2)) ? 0x04 : 0) |
-	       ((rt > SW_TRIG_ON || (b & TB_R2)) ? 0x08 : 0) |
-	       ((b & TB_MENU) ? 0x10 : 0) | ((b & TB_VIEW) ? 0x20 : 0) |
-	       ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
+	// Payload offsets = kernel buf[] index minus 1. buf[0..5] gyro bias stays
+	// zero (caller memset). Symmetric ranges → non-zero host divisors.
+	le16(buf + 6, 2844);
+	le16(buf + 8, -2844); // gyro pitch ±
+	le16(buf + 10, 2844);
+	le16(buf + 12, -2844); // gyro yaw ±
+	le16(buf + 14, 2844);
+	le16(buf + 16, -2844); // gyro roll ±
+	le16(buf + 18, 2844);
+	le16(buf + 20, 2844); // gyro speed ± (sum != 0)
+	le16(buf + 22, 8192);
+	le16(buf + 24, -8192); // accel X ±
+	le16(buf + 26, 8192);
+	le16(buf + 28, -8192); // accel Y ±
+	le16(buf + 30, 8192);
+	le16(buf + 32, -8192); // accel Z ±
 }
 
+// ---- Steam trackpad → absolute touch (internal helpers) --------------------
 static uint16_t pad_norm_u16(int16_t v, uint16_t maxv)
 {
 	int32_t t = (int32_t)v + 32768;
-	if (t < 0) t = 0;
-	if (t > 65535) t = 65535;
+	if (t < 0)
+		t = 0;
+	if (t > 65535)
+		t = 65535;
 	return (uint16_t)((t * (int32_t)maxv) / 65535);
 }
 static uint16_t touch_half_x(int16_t v, bool right_half)
@@ -89,7 +58,10 @@ static void touch_pack_point(uint8_t *base, int finger, bool touch, uint16_t x,
 {
 	uint8_t *f = base + finger * 4;
 	if (!touch) {
-		f[0] = 0x80; f[1] = 0; f[2] = 0; f[3] = 0;
+		f[0] = 0x80;
+		f[1] = 0;
+		f[2] = 0;
+		f[3] = 0;
 		return;
 	}
 	f[0] = (uint8_t)(finger & 0x7F);
@@ -121,6 +93,66 @@ void steam_pads_to_touch(uint32_t b, uint16_t touch_h, int16_t lpx, int16_t lpy,
 	*ly = touch_y_inv(lpy, touch_h);
 	*rx = touch_half_x(rpx, true);
 	*ry = touch_y_inv(rpy, touch_h);
-	if (lt && !(b & TB_LPADT)) { *lx = TOUCH_PAD_W / 4; *ly = touch_h / 2; }
-	if (rt && !(b & TB_RPADT)) { *rx = TOUCH_PAD_W / 4 * 3; *ry = touch_h / 2; }
+	if (lt && !(b & TB_LPADT)) {
+		*lx = TOUCH_PAD_W / 4;
+		*ly = touch_h / 2;
+	}
+	if (rt && !(b & TB_RPADT)) {
+		*rx = TOUCH_PAD_W / 4 * 3;
+		*ry = touch_h / 2;
+	}
+}
+
+uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt)
+{
+	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
+	       ((lt > SW_TRIG_ON || (b & TB_L2)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & TB_R2)) ? 0x08 : 0) |
+	       ((b & TB_MENU) ? 0x10 : 0) | ((b & TB_VIEW) ? 0x20 : 0) |
+	       ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
+}
+uint8_t ps_hat_nibble(uint32_t b)
+{
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
+	if (u && r)
+		return 1;
+	if (r && d)
+		return 3;
+	if (d && l)
+		return 5;
+	if (l && u)
+		return 7;
+	if (u)
+		return 0;
+	if (r)
+		return 2;
+	if (d)
+		return 4;
+	if (l)
+		return 6;
+	return 8;
+}
+uint8_t ps_face_nibble(uint32_t b, bool ab_swap)
+{
+	uint8_t f = 0;
+	if (ab_swap) {
+		if (b & TB_A)
+			f |= 0x40;
+		if (b & TB_B)
+			f |= 0x20;
+		if (b & TB_X)
+			f |= 0x80;
+		if (b & TB_Y)
+			f |= 0x10;
+	} else {
+		if (b & TB_A)
+			f |= 0x20;
+		if (b & TB_B)
+			f |= 0x40;
+		if (b & TB_X)
+			f |= 0x10;
+		if (b & TB_Y)
+			f |= 0x80;
+	}
+	return f;
 }

+ 96 - 0
OpenPuck/src/common/gamepad_util.h

@@ -0,0 +1,96 @@
+// gamepad_util.h — cross-mode report-building math shared by OpenPuck and
+// PicoPuck: analog-stick rescaling, Steam-trackpad → absolute-touch mapping, and
+// the PlayStation-layout button/hat/face/shoulder packers. Pure functions on the
+// canonical TB_* input — no config globals, no transport — so both firmwares link
+// the same code. (Config-coupled remapping — back-paddle / QAM / chord — stays in
+// each firmware since it reads that firmware's own settings source.)
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef OPK_COMMON_GAMEPAD_UTIL_H
+#define OPK_COMMON_GAMEPAD_UTIL_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "triton_masks.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Steam trackpad width; split into left/right halves so both pads co-exist as
+// two contacts on one DualSense/DS4 touchpad.
+#define TOUCH_PAD_W 1920u
+
+// Write a signed 16-bit value little-endian.
+static inline void le16(uint8_t *p, int16_t v)
+{
+	p[0] = (uint8_t)(v & 0xFF);
+	p[1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+// Configurable button code (1..20) → TB_* flag (shared code map).
+static inline uint32_t triton_from_code(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_QAM;
+	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;
+	case 19:
+		return TB_L2; // left trigger (LT / L2 / ZL)
+	case 20:
+		return TB_R2; // right trigger (RT / R2 / ZR)
+	default:
+		return 0;
+	}
+}
+
+uint8_t sw_stick(int16_t v, bool invert); // int16 center0 → u8 center0x80
+void ps_neutral_calib(uint8_t *buf); // DS4/DualSense motion-cal payload
+
+uint8_t ps_hat_nibble(uint32_t b); // d-pad → 8-way hat (8 = neutral)
+uint8_t ps_face_nibble(uint32_t b,
+		       bool ab_swap); // PS face nibble (opt A/B+X/Y swap)
+uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt);
+
+void steam_pads_to_touch(uint32_t b, uint16_t touch_h, int16_t lpx, int16_t lpy,
+			 int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+			 uint16_t *rx, uint16_t *ry);
+void touch_pack_pads(uint8_t *pts, bool l_touch, bool r_touch, uint16_t lx,
+		     uint16_t ly, uint16_t rx, uint16_t ry);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // OPK_COMMON_GAMEPAD_UTIL_H

+ 157 - 0
OpenPuck/src/common/hd_rumble.c

@@ -0,0 +1,157 @@
+// hd_rumble.c — see hd_rumble.h. Curve constants are protocol facts (any correct
+// decoder lands on the same numbers); cross-checked against SDL's hidapi_switch
+// rumble encoder and community RE notes.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "hd_rumble.h"
+#include <math.h>
+
+enum { HDR_AMP_MIN = -256, HDR_AMP_OFF = -256 }; // -8.0 log2 units == silent
+
+// Absolute 7-bit amplitude code → 1/32 log2 units (piecewise linear, slopes
+// 1/4, 1/16, 1/32; code 0 is silence).
+static int16_t hdr_amp7(uint8_t code)
+{
+	if (code == 0)
+		return HDR_AMP_MIN;
+	if (code < 16)
+		return (int16_t)(8 * (int)code - 248);
+	if (code < 32)
+		return (int16_t)(2 * (int)code - 158);
+	return (int16_t)((int)code - 127);
+}
+
+// Compact 5-bit command applied to the running amplitude: 0 silence; 1..11 an
+// absolute preset 0..-5.0; 17..22 step up; 26..31 step down; else unchanged.
+static int16_t hdr_amp5(uint8_t code, int16_t cur)
+{
+	if (code == 0)
+		return HDR_AMP_OFF;
+	if (code <= 11)
+		return (int16_t)(-16 * (int)(code - 1));
+	int step = 0;
+	if (code >= 17 && code <= 19)
+		step = 4;
+	else if (code >= 20 && code <= 22)
+		step = 1;
+	else if (code >= 26 && code <= 28)
+		step = -1;
+	else if (code >= 29 && code <= 31)
+		step = -4;
+	int v = (int)cur + step;
+	return v < HDR_AMP_MIN ? HDR_AMP_MIN : (v > 0 ? 0 : (int16_t)v);
+}
+
+// exp2(units/32) scaled to a 16-bit level, built once on first decode.
+static uint16_t s_level[257];
+static int s_built;
+static void build_levels(void)
+{
+	if (s_built)
+		return;
+	for (int u = HDR_AMP_MIN; u <= 0; u++) {
+		float lin = (float)u / 32.0f;
+		float amp = (lin >= -7.9375f) ? exp2f(lin) : 0.0f;
+		if (amp > 1.0f)
+			amp = 1.0f;
+		uint32_t v = (uint32_t)(amp * 65535.0f + 0.5f);
+		s_level[u - HDR_AMP_MIN] = (v > 0xFFFF) ? 0xFFFF : (uint16_t)v;
+	}
+	s_built = 1;
+}
+
+static uint8_t hdr_field(uint32_t w, uint8_t shift, uint8_t width)
+{
+	return (uint8_t)((w >> shift) & ((1u << width) - 1u));
+}
+
+void hdr_reset(hdr_band_t *band)
+{
+	band->lo = HDR_AMP_OFF;
+	band->hi = HDR_AMP_OFF;
+}
+
+uint16_t hdr_decode(hdr_band_t *band, const uint8_t b4[4])
+{
+	build_levels();
+	int16_t *lo = &band->lo, *hi = &band->hi;
+	uint32_t w = (uint32_t)b4[0] | ((uint32_t)b4[1] << 8) |
+		     ((uint32_t)b4[2] << 16) | ((uint32_t)b4[3] << 24);
+	uint16_t peak = 0;
+#define HDR_SAMPLE()                                      \
+	do {                                              \
+		uint16_t la = s_level[*lo - HDR_AMP_MIN]; \
+		uint16_t ha = s_level[*hi - HDR_AMP_MIN]; \
+		uint16_t lv = la > ha ? la : ha;          \
+		if (lv > peak)                            \
+			peak = lv;                        \
+	} while (0)
+
+	switch (hdr_field(w, 30, 2)) {
+	case 0:
+		HDR_SAMPLE();
+		break;
+	case 1:
+		if ((w & 0xFFFFF) == 0) {
+			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+			HDR_SAMPLE();
+		} else if ((w & 0x3) == 0) {
+			*lo = hdr_amp7(hdr_field(w, 23, 7));
+			*hi = hdr_amp7(hdr_field(w, 9, 7));
+			HDR_SAMPLE();
+		} else {
+			int want_hi = (w & 1) != 0;
+			int is_freq = ((w >> 2) & 1) != 0;
+			if (!is_freq) {
+				if (want_hi)
+					*hi = hdr_amp7(hdr_field(w, 23, 7));
+				else
+					*lo = hdr_amp7(hdr_field(w, 23, 7));
+			}
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 13, 5), *hi);
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 8, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 3, 5), *hi);
+			HDR_SAMPLE();
+		}
+		break;
+	case 2:
+		if ((w & 0x3FF) == 0) {
+			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
+			HDR_SAMPLE();
+		} else {
+			if (w & 1) {
+				*hi = hdr_amp7(hdr_field(w, 23, 7));
+				*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
+			} else {
+				*lo = hdr_amp7(hdr_field(w, 23, 7));
+				*hi = hdr_amp5(hdr_field(w, 18, 5), *hi);
+			}
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 13, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 8, 5), *hi);
+			HDR_SAMPLE();
+		}
+		break;
+	case 3:
+		*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+		HDR_SAMPLE();
+		*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
+		HDR_SAMPLE();
+		*lo = hdr_amp5(hdr_field(w, 5, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 0, 5), *hi);
+		HDR_SAMPLE();
+		break;
+	}
+#undef HDR_SAMPLE
+	return peak;
+}

+ 42 - 0
OpenPuck/src/common/hd_rumble.h

@@ -0,0 +1,42 @@
+// hd_rumble.h — Nintendo Switch HD-rumble amplitude decoder, shared byte-for-byte
+// by OpenPuck and PicoPuck (both drive the SC2's single ERM from the console's
+// dual-band HD-rumble stream, so both need the same scalar-amplitude decode).
+//
+// When emulating a Pro Controller the console streams a 4-byte word per motor
+// each frame; the top two bits select how many amplitude/frequency updates are
+// packed. A naive fixed-byte decode mis-reads the packed modes as spurious
+// amplitude (phantom menu buzzing), so the full mode handling here is required.
+// Amplitude is carried in 1/32 log2 units over [-8, 0] → integer [-256, 0],
+// mapped to a 16-bit level via a boot-built exp2 table (no FP in the hot path).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef OPK_COMMON_HD_RUMBLE_H
+#define OPK_COMMON_HD_RUMBLE_H
+
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// One motor's running low/high band amplitudes (1/32 log2 units). Packed 5-bit
+// commands are relative, so this state must persist across frames — keep one
+// hdr_band_t per (slot, motor).
+typedef struct {
+	int16_t lo;
+	int16_t hi;
+} hdr_band_t;
+
+// Reset a motor's bands to silent. Call per motor at init / (re)mount.
+void hdr_reset(hdr_band_t *band);
+
+// Decode one motor's 4 rumble bytes, advancing *band, and return the PEAK 16-bit
+// motor level over the frame's updates. Every idle/neutral frame resolves to 0.
+// The exp2 level table is built on the first call.
+uint16_t hdr_decode(hdr_band_t *band, const uint8_t b4[4]);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // OPK_COMMON_HD_RUMBLE_H

+ 2 - 1
PicoPuck/CMakeLists.txt

@@ -47,7 +47,6 @@ target_sources(picopuck PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/relay.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/triton.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/chord.c
-    ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/gamepad_util.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/emu.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/emu_present.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_switch_hori.c
@@ -67,6 +66,8 @@ target_sources(picopuck PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/drv_ds4.c
     # Shared source of truth, compiled by BOTH firmwares (see ../OpenPuck/src/common).
     ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/hid_reports.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/hd_rumble.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/gamepad_util.c
 )
 
 target_include_directories(picopuck PRIVATE

+ 0 - 26
PicoPuck/src/puck/gamepad_util.h

@@ -1,26 +0,0 @@
-// gamepad_util.h — shared report-building helpers (ported from OpenPuck).
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-#ifndef PICOPUCK_GAMEPAD_UTIL_H
-#define PICOPUCK_GAMEPAD_UTIL_H
-
-#include <stdint.h>
-#include <stdbool.h>
-#include "puck/triton.h" // brings in SW_TRIG_ON via the shared triton_masks.h
-
-#define TOUCH_PAD_W 1920u
-
-uint8_t sw_stick(int16_t v, bool invert); // int16 center0 → u8 center0x80
-void ps_neutral_calib(uint8_t *buf); // DS4/DualSense motion calib payload
-
-uint8_t ps_hat_nibble(uint32_t b); // d-pad → 8-way hat (8 = neutral)
-uint8_t ps_face_nibble(uint32_t b); // PlayStation face nibble
-uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt);
-
-void steam_pads_to_touch(uint32_t b, uint16_t touch_h, int16_t lpx, int16_t lpy,
-			 int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
-			 uint16_t *rx, uint16_t *ry);
-void touch_pack_pads(uint8_t *pts, bool l_touch, bool r_touch, uint16_t lx,
-		     uint16_t ly, uint16_t rx, uint16_t ry);
-
-#endif // PICOPUCK_GAMEPAD_UTIL_H

+ 5 - 2
PicoPuck/src/puck/mode_ds4.c

@@ -4,13 +4,15 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu.h"
-#include "puck/gamepad_util.h"
+#include "gamepad_util.h"
 #include "puck/relay.h"
+#include "sys/settings.h"
 #include <string.h>
 #include "hid_reports.h"
 
 #define DS4_TOUCH_H 942
 #define DS4_STATUS_USB 0x1B
+#define ET_DS4 2 // per-type config index (matches OpenPuck ET_DS4)
 
 static uint16_t ds4_build(int slot, uint8_t *out, uint8_t *rid)
 {
@@ -23,7 +25,8 @@ static uint16_t ds4_build(int slot, uint8_t *out, uint8_t *rid)
 	out[1] = sw_stick(g_in[slot].ly, true);
 	out[2] = sw_stick(g_in[slot].rx, false);
 	out[3] = sw_stick(g_in[slot].ry, true);
-	out[4] = ps_hat_nibble(b) | ps_face_nibble(b);
+	out[4] = ps_hat_nibble(b) |
+		 ps_face_nibble(b, settings()->type[ET_DS4].ab_swap);
 	out[5] = ps_shoulders_byte(b, g_in[slot].lt, g_in[slot].rt);
 	static uint8_t ctr;
 	out[6] = ((ctr++ & 0x0F) << 4) |

+ 1 - 1
PicoPuck/src/puck/mode_ps3.c

@@ -10,7 +10,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu.h"
-#include "puck/gamepad_util.h"
+#include "gamepad_util.h"
 #include "puck/relay.h"
 #include <string.h>
 #include "hid_reports.h"

+ 5 - 2
PicoPuck/src/puck/mode_ps5.c

@@ -4,13 +4,15 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu.h"
-#include "puck/gamepad_util.h"
+#include "gamepad_util.h"
 #include "puck/relay.h"
+#include "sys/settings.h"
 #include <string.h>
 #include "hid_reports.h"
 
 #define PS5_TOUCH_H 1080
 #define PS5_STATUS_USB 0x1A
+#define ET_DS5 3 // per-type config index (matches OpenPuck ET_DS5)
 
 static uint16_t ps5_build(int slot, uint8_t *out, uint8_t *rid)
 {
@@ -27,7 +29,8 @@ static uint16_t ps5_build(int slot, uint8_t *out, uint8_t *rid)
 	out[5] = g_in[slot].rt;
 	static uint8_t seq;
 	out[6] = seq++;
-	out[7] = ps_hat_nibble(b) | ps_face_nibble(b);
+	out[7] = ps_hat_nibble(b) |
+		 ps_face_nibble(b, settings()->type[ET_DS5].ab_swap);
 	out[8] = ps_shoulders_byte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
 		 ((b & (TB_TOUCH | TB_LPADC | TB_RPADC)) ? 0x02 : 0) |

+ 1 - 1
PicoPuck/src/puck/mode_switch_hori.c

@@ -5,7 +5,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu.h"
-#include "puck/gamepad_util.h"
+#include "gamepad_util.h"
 #include "hid_reports.h"
 
 static uint16_t hori_build(int slot, uint8_t *out, uint8_t *rid)

+ 15 - 141
PicoPuck/src/puck/mode_switch_pro.c

@@ -13,16 +13,16 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu.h"
-#include "puck/gamepad_util.h"
+#include "gamepad_util.h"
 #include "puck/relay.h"
 #include "puck/slots.h"
 #include "sys/settings.h"
 #include "config/modes.h"
 
 #include <string.h>
-#include <math.h>
 #include "pico/time.h"
 #include "hid_reports.h"
+#include "hd_rumble.h"
 
 #define ET_SWITCH 1 // per-type config index (matches OpenPuck ET_SWITCH)
 #define SW_PRO_REPORT_MS 15u // 66 Hz
@@ -107,151 +107,25 @@ static void jc_mac(int slot, uint8_t out[6])
 static uint8_t g_jcTimer[PP_NSLOT];
 static uint8_t g_reportMode[PP_NSLOT]; // 0 until subcommand 0x03 selects 0x30
 
-// ---- HD-rumble amplitude decoder (see OpenPuck for the full protocol notes).
-enum { HDR_AMP_MIN = -256, HDR_AMP_OFF = -256 };
-static int16_t hdr_amp7(uint8_t code)
-{
-	if (code == 0)
-		return HDR_AMP_MIN;
-	if (code < 16)
-		return (int16_t)(8 * (int)code - 248);
-	if (code < 32)
-		return (int16_t)(2 * (int)code - 158);
-	return (int16_t)((int)code - 127);
-}
-static int16_t hdr_amp5(uint8_t code, int16_t cur)
-{
-	if (code == 0)
-		return HDR_AMP_OFF;
-	if (code <= 11)
-		return (int16_t)(-16 * (int)(code - 1));
-	int step = 0;
-	if (code >= 17 && code <= 19)
-		step = 4;
-	else if (code >= 20 && code <= 22)
-		step = 1;
-	else if (code >= 26 && code <= 28)
-		step = -1;
-	else if (code >= 29 && code <= 31)
-		step = -4;
-	int v = (int)cur + step;
-	return v < HDR_AMP_MIN ? HDR_AMP_MIN : (v > 0 ? 0 : (int16_t)v);
-}
-static uint16_t g_hdr_level[257];
-static bool g_hdr_built;
-static void hdr_build_levels(void)
-{
-	if (g_hdr_built)
-		return;
-	for (int u = HDR_AMP_MIN; u <= 0; u++) {
-		float lin = (float)u / 32.0f;
-		float amp = (lin >= -7.9375f) ? exp2f(lin) : 0.0f;
-		if (amp > 1.0f)
-			amp = 1.0f;
-		uint32_t v = (uint32_t)(amp * 65535.0f + 0.5f);
-		g_hdr_level[u - HDR_AMP_MIN] = (v > 0xFFFF) ? 0xFFFF :
-							      (uint16_t)v;
-	}
-	g_hdr_built = true;
-}
-// Running amplitudes per slot, per motor (0=L,1=R); packed 5-bit commands are
-// relative, so this must persist between frames — and be independent per pad.
-static int16_t g_band_lo[PP_NSLOT][2], g_band_hi[PP_NSLOT][2];
-static uint8_t hdr_field(uint32_t w, uint8_t shift, uint8_t width)
-{
-	return (uint8_t)((w >> shift) & ((1u << width) - 1u));
-}
-static uint16_t hdr_decode(int slot, uint8_t motor, const uint8_t b[4])
-{
-	int16_t *lo = &g_band_lo[slot][motor], *hi = &g_band_hi[slot][motor];
-	uint32_t w = (uint32_t)b[0] | ((uint32_t)b[1] << 8) |
-		     ((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
-	uint16_t peak = 0;
-#define HDR_SAMPLE()                                          \
-	do {                                                  \
-		uint16_t la = g_hdr_level[*lo - HDR_AMP_MIN]; \
-		uint16_t ha = g_hdr_level[*hi - HDR_AMP_MIN]; \
-		uint16_t lv = la > ha ? la : ha;              \
-		if (lv > peak)                                \
-			peak = lv;                            \
-	} while (0)
+// HD-rumble decoding lives in the shared module (common/hd_rumble.c),
+// byte-for-byte identical to OpenPuck. Per-slot, per-motor band state:
+static hdr_band_t g_bands[PP_NSLOT][2];
+static bool g_bands_init;
 
-	switch (hdr_field(w, 30, 2)) {
-	case 0:
-		HDR_SAMPLE();
-		break;
-	case 1:
-		if ((w & 0xFFFFF) == 0) {
-			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
-			HDR_SAMPLE();
-		} else if ((w & 0x3) == 0) {
-			*lo = hdr_amp7(hdr_field(w, 23, 7));
-			*hi = hdr_amp7(hdr_field(w, 9, 7));
-			HDR_SAMPLE();
-		} else {
-			bool want_hi = (w & 1) != 0;
-			bool is_freq = ((w >> 2) & 1) != 0;
-			if (!is_freq) {
-				if (want_hi)
-					*hi = hdr_amp7(hdr_field(w, 23, 7));
-				else
-					*lo = hdr_amp7(hdr_field(w, 23, 7));
-			}
-			HDR_SAMPLE();
-			*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 13, 5), *hi);
-			HDR_SAMPLE();
-			*lo = hdr_amp5(hdr_field(w, 8, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 3, 5), *hi);
-			HDR_SAMPLE();
-		}
-		break;
-	case 2:
-		if ((w & 0x3FF) == 0) {
-			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
-			HDR_SAMPLE();
-			*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
-			HDR_SAMPLE();
-		} else {
-			if (w & 1) {
-				*hi = hdr_amp7(hdr_field(w, 23, 7));
-				*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
-			} else {
-				*lo = hdr_amp7(hdr_field(w, 23, 7));
-				*hi = hdr_amp5(hdr_field(w, 18, 5), *hi);
-			}
-			HDR_SAMPLE();
-			*lo = hdr_amp5(hdr_field(w, 13, 5), *lo);
-			*hi = hdr_amp5(hdr_field(w, 8, 5), *hi);
-			HDR_SAMPLE();
-		}
-		break;
-	case 3:
-		*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
-		*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
-		HDR_SAMPLE();
-		*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
-		*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
-		HDR_SAMPLE();
-		*lo = hdr_amp5(hdr_field(w, 5, 5), *lo);
-		*hi = hdr_amp5(hdr_field(w, 0, 5), *hi);
-		HDR_SAMPLE();
-		break;
-	}
-#undef HDR_SAMPLE
-	return peak;
-}
 static uint16_t g_jc_last_lo[PP_NSLOT], g_jc_last_hi[PP_NSLOT];
 static void jc_rumble(int slot, const uint8_t *p, uint16_t pn)
 {
 	if (pn < 9)
 		return; // [timer][left x4][right x4]
-	hdr_build_levels();
-	uint16_t lo = hdr_decode(slot, 0, p + 1),
-		 hi = hdr_decode(slot, 1, p + 5);
+	if (!g_bands_init) {
+		for (int i = 0; i < PP_NSLOT; i++) {
+			hdr_reset(&g_bands[i][0]);
+			hdr_reset(&g_bands[i][1]);
+		}
+		g_bands_init = true;
+	}
+	uint16_t lo = hdr_decode(&g_bands[slot][0], p + 1),
+		 hi = hdr_decode(&g_bands[slot][1], p + 5);
 	if (lo == g_jc_last_lo[slot] && hi == g_jc_last_hi[slot])
 		return; // Switch streams rumble every frame; relay only on change
 	g_jc_last_lo[slot] = lo;