Add live profile playtest and input fixes

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 19:37:58 -06:00
commit 890f47ab80
20 changed files with 1083 additions and 20 deletions

View file

@ -131,13 +131,13 @@ uv run switch-pico-config pairings list
uv run switch-pico-config pairings clear --yes
```
Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use a separate two-bank atomic store before the adapter and Bluepad32 bond regions. Profile writes are chunked, verified before the new bank is committed, recover the previous generation after interruption or corruption, skip unchanged data, and are rate-limited.
Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use separate append-only arenas before the adapter and Bluepad32 bond regions. Profile writes are individually checksummed, recover the previous record after interruption or corruption, compact atomically between arenas, skip unchanged data, and are rate-limited.
Output mode is selected before TinyUSB starts and never changes while mounted. A mode command atomically stores the selection, resets synthetic input, reboots, follows the same physical USB port through re-enumeration, and verifies requested versus active mode. `auto` uses the verified Switch probe → Windows XInput transition; manual modes bypass probing. The controller chord **L + R + Select + Start + System** held for three seconds cycles `auto → switch → xinput → dinput → mac → auto`. The destructive ten-second BOOTSEL pairing reset also restores `auto` before reboot, providing physical recovery.
Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble.
`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Select a controller identity and one of its four profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another localhost port.
`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port.
The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets.
@ -228,7 +228,7 @@ swift tools/Test-SwitchPicoMac.swift
The diagnostic matches only `CAFE:4021`, identifies each of the four interfaces by interface and location, and prints changed axes, hats, and buttons with their HID usage and logical range. The four signed stick axes remain `X`/`Y`/`Z`/`Rx`. Move each analog trigger slowly and confirm output such as `LeftBrake page=0x02 usage=0xC5 logical=0...65535 value=32768` and `RightAccelerator page=0x02 usage=0xC4 logical=0...65535 value=32768`; each trigger should traverse intermediate values across `0...65535`, not only the endpoints. The diagnostic continues through hot-plug events until Ctrl-C. If opening a device fails, allow the terminal (or the app launching Swift) under **System Settings → Privacy & Security → Input Monitoring**, then rerun it.
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes.
On the tested Linux host, all four HID interfaces enumerate immediately at the USB layer, but `auto` initially presents them as a composite Nintendo Pro Controller while probing the host. Linux binds `hid-nintendo` to each interface and performs synchronous handshake and calibration requests with retries; incomplete composite interoperability causes `-110` timeouts and can accumulate into a 1530 second user-visible delay before the transient hidraw nodes are removed. The timeout is not observed on the Switch. For a Linux laptop, persist `dinput` for immediate generic-HID enumeration (`uv run switch-pico-config mode dinput`) or `xinput` when rumble is required, then restore `auto` or `switch` before console use. Profile management uses endpoint-zero vendor transfers and does not depend on `hid-nintendo`.
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.

View file

@ -102,7 +102,13 @@ index a22ef26..3d5ecef 100644
ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
ins->gyro_calib_data[2].sens_denom =
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
@@ -618,11 +618,11 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
if (r->buttons[2] & 0x01)
ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS
- if (r->buttons[2] & 0x04)
- ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // "mute" button
+ if (r->buttons[2] & 0x02)
+ ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // Touchpad click
// Gyro
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {

View file

@ -1888,6 +1888,27 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index,
g_last_snapshot_generation[slot_index] = state_generation;
}
void bluepad32_input_backend_playtest_snapshot(
uint8_t slot_index, Bluepad32PlaytestSnapshot* out) {
if (out == nullptr) {
return;
}
*out = {};
if (!valid_slot(slot_index) || !g_initialized) {
return;
}
critical_section_enter_blocking(&g_state_lock);
const BackendSlot& slot = g_slots[slot_index];
out->active = slot.active;
out->connection_generation = slot.connection_generation;
out->state_generation = slot.state_generation;
out->identity = slot.identity;
out->physical_button_mask = slot.pre_hotkey_button_mask;
out->state = slot.state;
critical_section_exit(&g_state_lock);
}
bool bluepad32_input_backend_toggle_motion(
uint8_t slot_index, uint32_t connection_generation) {
if (!g_initialized || !valid_slot(slot_index)) {

View file

@ -62,6 +62,17 @@ struct Bluepad32SlotSnapshot {
ControllerState state;
};
// Side-effect-free raw input snapshot for management telemetry. Unlike the
// report-path snapshot, reading this does not consume motion samples.
struct Bluepad32PlaytestSnapshot {
bool active = false;
uint32_t connection_generation = 0;
uint32_t state_generation = 0;
ControllerIdentity identity{};
uint16_t physical_button_mask = 0;
ControllerState state{};
};
struct Bluepad32BackendDiagnostics {
uint32_t initialization_stage;
uint32_t rumble_timer_ticks;
@ -86,6 +97,8 @@ void bluepad32_input_backend_open_pairing_window();
uint32_t bluepad32_input_backend_clear_pairings();
void bluepad32_input_backend_snapshot(uint8_t slot,
Bluepad32SlotSnapshot* out);
void bluepad32_input_backend_playtest_snapshot(
uint8_t slot, Bluepad32PlaytestSnapshot* out);
void bluepad32_input_backend_request_pairing_snapshot();
void bluepad32_input_backend_pairing_snapshot(
Bluepad32PairingSnapshot* out);
@ -101,7 +114,7 @@ void bluepad32_input_backend_queue_rumble(
// Enqueue bounded local profile confirmation for the matching live connection
// generation. The two-entry per-slot FIFO preserves initial-then-switch
// ordering. Profile lighting is transient and restored to the steady slot
// indication after the final gap. Profile numbers are one-based (1..4).
// indication after the final gap. Profile numbers are one-based (1..8).
void bluepad32_input_backend_queue_profile_feedback(
uint8_t slot, uint32_t connection_generation,
uint8_t active_profile_number,

View file

@ -287,6 +287,66 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
payload, offset, output, output_size);
}
size_t encode_profile_playtest(
uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot,
uint8_t* output, size_t output_size) {
uint8_t payload[kProfilePlaytestPayloadSize]{};
payload[1] = 0xff;
if (snapshot.active) {
if (slot >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT ||
!controller_identity_encode(
snapshot.identity, &payload[12],
CONTROLLER_IDENTITY_ENCODED_SIZE) ||
snapshot.state.motion_sample_count >
CONTROLLER_MOTION_SAMPLE_CAPACITY) {
return 0;
}
payload[0] = 1;
payload[1] = slot;
write_u16(&payload[2], snapshot.physical_button_mask);
write_u32(&payload[4], snapshot.connection_generation);
write_u32(&payload[8], snapshot.state_generation);
write_u16(
&payload[26],
static_cast<uint16_t>(snapshot.state.left_stick_x));
write_u16(
&payload[28],
static_cast<uint16_t>(snapshot.state.left_stick_y));
write_u16(
&payload[30],
static_cast<uint16_t>(snapshot.state.right_stick_x));
write_u16(
&payload[32],
static_cast<uint16_t>(snapshot.state.right_stick_y));
write_u16(&payload[34], snapshot.state.left_trigger);
write_u16(&payload[36], snapshot.state.right_trigger);
payload[38] = snapshot.state.motion_sample_count;
if (snapshot.state.motion_sample_count != 0) {
payload[0] |= 2;
const ControllerMotionSample& motion =
snapshot.state.motion_samples[
snapshot.state.motion_sample_count - 1u];
write_u16(&payload[40],
static_cast<uint16_t>(motion.accel_x));
write_u16(&payload[42],
static_cast<uint16_t>(motion.accel_y));
write_u16(&payload[44],
static_cast<uint16_t>(motion.accel_z));
write_u16(&payload[46],
static_cast<uint16_t>(motion.gyro_x));
write_u16(&payload[48],
static_cast<uint16_t>(motion.gyro_y));
write_u16(&payload[50],
static_cast<uint16_t>(motion.gyro_z));
}
}
return encode_response(
Operation::kProfilePlaytest, Status::kOk, payload[0],
kProfilePlaytestSchemaVersion,
snapshot.active ? snapshot.state_generation : 0,
payload, sizeof(payload), output, output_size);
}
size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
uint8_t* output, size_t output_size) {
Status status = profile_service_status(snapshot.metadata);
@ -622,6 +682,30 @@ bool usb_configuration_management_vendor_control(
snapshot, response, sizeof(response));
break;
}
case Operation::kProfilePlaytest: {
ProfileServiceSelectedSnapshot selected{};
profile_service_selected_snapshot(&selected);
Bluepad32PlaytestSnapshot playtest{};
uint8_t selected_slot = 0xff;
for (uint8_t slot = 0;
slot < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++slot) {
Bluepad32PlaytestSnapshot candidate{};
bluepad32_input_backend_playtest_snapshot(
slot, &candidate);
if (!candidate.active ||
(!controller_identity_is_global(selected.identity) &&
!controller_identity_equal(
selected.identity, candidate.identity))) {
continue;
}
playtest = candidate;
selected_slot = slot;
break;
}
response_size = encode_profile_playtest(
selected_slot, playtest, response, sizeof(response));
break;
}
case Operation::kProfileTransactionStatus: {
ProfileServiceTransactionSnapshot snapshot{};
profile_service_transaction_snapshot(&snapshot);

View file

@ -21,6 +21,8 @@ constexpr size_t kPairingPayloadHeaderSize = 4;
constexpr size_t kMaximumRequestSize = 64;
constexpr size_t kProfileListPayloadSize =
1 + PROFILE_SERVICE_LIST_CAPACITY * 16;
constexpr uint16_t kProfilePlaytestSchemaVersion = 1;
constexpr size_t kProfilePlaytestPayloadSize = 52;
constexpr size_t kMaximumResponseSize =
kResponseHeaderSize + kProfileListPayloadSize;
constexpr size_t kMaximumChunkSize =
@ -52,6 +54,7 @@ enum class Operation : uint8_t {
kProfileReset = 0x36,
kProfileActivate = 0x37,
kProfileTransactionStatus = 0x38,
kProfilePlaytest = 0x39,
};
enum class Status : uint8_t {
@ -84,6 +87,9 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
uint8_t* output, size_t output_size);
size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
uint8_t* output, size_t output_size);
size_t encode_profile_playtest(
uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot,
uint8_t* output, size_t output_size);
size_t encode_profile_transaction(
const ProfileServiceTransactionSnapshot& snapshot,
uint8_t* output, size_t output_size);

View file

@ -59,6 +59,7 @@ OP_PROFILE_COMMIT = 0x35
OP_PROFILE_RESET = 0x36
OP_PROFILE_ACTIVATE = 0x37
OP_PROFILE_TRANSACTION_STATUS = 0x38
OP_PROFILE_PLAYTEST = 0x39
STATUS_OK = 0
STATUS_PENDING = 1
@ -120,6 +121,9 @@ PROFILE_MACRO_STEP_SIZE = 19
PROFILE_MAXIMUM_WAIT_MS = 10000
PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000
PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934
PROFILE_PLAYTEST_SCHEMA_VERSION = 1
PROFILE_PLAYTEST_SIZE = 52
PROFILE_PLAYTEST_SLOT_COUNT = 4
LOGICAL_BUTTONS = (
"south",
@ -507,6 +511,57 @@ class ProfileListEntry:
)
@dataclass(frozen=True)
class ProfilePlaytest:
connected: bool
slot_index: int | None
connection_generation: int
state_generation: int
identity: ControllerIdentity | None
button_mask: int
left_stick: tuple[int, int]
right_stick: tuple[int, int]
triggers: tuple[int, int]
motion: tuple[int, int, int, int, int, int] | None
def to_json_object(self) -> dict[str, Any]:
return {
"connected": self.connected,
"slot": self.slot_index,
"connection_generation": self.connection_generation,
"state_generation": self.state_generation,
"identity": (
{
"address": self.identity.address_text,
"vendor_id": self.identity.vendor_id,
"product_id": self.identity.product_id,
}
if self.identity is not None
else None
),
"buttons": _button_mask_to_json(self.button_mask),
"left_stick": {
"x": self.left_stick[0],
"y": self.left_stick[1],
},
"right_stick": {
"x": self.right_stick[0],
"y": self.right_stick[1],
},
"triggers": {
"left": self.triggers[0],
"right": self.triggers[1],
},
"motion": (
{
"accel": list(self.motion[:3]),
"gyro": list(self.motion[3:]),
}
if self.motion is not None
else None
),
}
@dataclass(frozen=True)
class StickConfig:
center_x: int
@ -2069,6 +2124,58 @@ def read_profile(
return read_selected_profile(device)
def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
_raise_status(envelope)
if (
envelope.schema_version != PROFILE_PLAYTEST_SCHEMA_VERSION
or len(envelope.payload) != PROFILE_PLAYTEST_SIZE
):
raise ConfigManagerError("invalid profile playtest payload")
payload = envelope.payload
flags = payload[0]
if flags & ~0x03 or flags != envelope.flags or payload[39] != 0:
raise ConfigManagerError("invalid profile playtest flags")
connected = bool(flags & 0x01)
has_motion = bool(flags & 0x02)
motion_count = payload[38]
if not connected:
if flags != 0 or payload[1] != 0xFF or any(payload[2:]):
raise ConfigManagerError("invalid disconnected playtest payload")
return ProfilePlaytest(
False, None, 0, 0, None, 0,
(0, 0), (0, 0), (0, 0), None,
)
if (
payload[1] >= PROFILE_PLAYTEST_SLOT_COUNT
or not 0 <= motion_count <= 3
or has_motion != (motion_count != 0)
):
raise ConfigManagerError("invalid connected playtest payload")
identity = ControllerIdentity.from_bytes(payload[12:26])
left_x, left_y, right_x, right_y, left_trigger, right_trigger = (
struct.unpack_from("<hhhhHH", payload, 26)
)
motion_values = struct.unpack_from("<hhhhhh", payload, 40)
if not has_motion and any(motion_values):
raise ConfigManagerError("playtest motion sample was not declared")
return ProfilePlaytest(
connected=True,
slot_index=payload[1],
connection_generation=struct.unpack_from("<I", payload, 4)[0],
state_generation=struct.unpack_from("<I", payload, 8)[0],
identity=identity,
button_mask=struct.unpack_from("<H", payload, 2)[0],
left_stick=(left_x, left_y),
right_stick=(right_x, right_y),
triggers=(left_trigger, right_trigger),
motion=motion_values if has_motion else None,
)
def read_profile_playtest(device: UsbDevice) -> ProfilePlaytest:
return parse_profile_playtest(_control_in(device, OP_PROFILE_PLAYTEST))
def read_profile_transaction_status(device: UsbDevice) -> TransactionStatus:
envelope = _control_in(device, OP_PROFILE_TRANSACTION_STATUS)
_raise_status(envelope, pending_ok=True)

View file

@ -18,6 +18,10 @@ _MAXIMUM_REQUEST_BYTES = 64 * 1024
_ASSET_TYPES = {
"/": ("profile_editor.html", "text/html; charset=utf-8"),
"/app.js": ("profile_editor.js", "text/javascript; charset=utf-8"),
"/playtest.js": (
"profile_playtest.js",
"text/javascript; charset=utf-8",
),
"/style.css": ("profile_editor.css", "text/css; charset=utf-8"),
"/assets/controller-switch-pro.svg": (
"assets/controller-switch-pro.svg",
@ -128,11 +132,17 @@ class ProfileEditorServer(HTTPServer):
self.device_address = device_address
self.operation_timeout = timeout
self.mutation_token = secrets.token_urlsafe(32)
self._device: config_manager.UsbDevice | None = None
def find_device(self) -> config_manager.UsbDevice:
return config_manager.find_pico(
self.bus, self.device_address, self.operation_timeout
)
if self._device is None:
self._device = config_manager.find_pico(
self.bus, self.device_address, self.operation_timeout
)
return self._device
def invalidate_device(self) -> None:
self._device = None
class ProfileEditorHandler(BaseHTTPRequestHandler):
@ -200,6 +210,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
lambda: self._read_profile(identity_index, profile_index)
)
return
if action == "playtest":
self._api_call(self._read_playtest)
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None:
@ -288,6 +301,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
{
"index": index,
"label": _controller_label(entry.identity),
"key": entry.identity.to_bytes().hex(),
"active_profile": entry.active_profile_index + 1,
"controller": _controller_presentation(entry.identity),
}
@ -306,6 +320,22 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
"active": entries[identity_index].active_profile_index == profile_index,
}
def _read_playtest(self) -> dict[str, Any]:
device = self.profile_server.find_device()
playtest = config_manager.read_profile_playtest(device)
result = playtest.to_json_object()
result["controller"] = (
_controller_presentation(playtest.identity)
if playtest.identity is not None
else None
)
result["label"] = (
_controller_label(playtest.identity)
if playtest.identity is not None
else None
)
return result
def _read_json_body(self) -> str:
try:
length = int(self.headers.get("Content-Length", ""))
@ -358,6 +388,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
except config_manager.ConfigManagerError as exc:
self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
except usb.core.USBError as exc:
self.profile_server.invalidate_device()
self._send_json(
{"error": f"USB access failed: {exc}"},
status=HTTPStatus.SERVICE_UNAVAILABLE,

View file

@ -417,6 +417,45 @@ h4 { font-size: 1rem; }
.control-card:hover { border-color: #415273; }
.control-card label, .number-field label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 0.7rem; font-weight: 760; letter-spacing: 0.035em; }
.control-card .select { min-height: 40px; }
.playtest-panel { margin-bottom: 18px; padding: 19px; border: 1px solid rgba(89, 199, 255, 0.28); border-radius: 17px; background: #0b1324; }
.playtest-panel[data-state="waiting"] .playtest-grid { opacity: 0.48; }
.playtest-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.playtest-heading h4 { margin-top: 3px; }
.playtest-status { padding: 5px 9px; color: var(--amber); border: 1px solid currentColor; border-radius: 999px; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
.playtest-panel[data-state="live"] .playtest-status { color: var(--mint); }
.playtest-panel[data-state="error"] .playtest-status { color: var(--rose); }
.playtest-help { margin: 7px 0 16px; color: var(--muted); font-size: 0.73rem; }
.playtest-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(300px, 1.4fr); gap: 12px; transition: opacity 150ms ease; }
.playtest-card { min-width: 0; padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); }
.playtest-card-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 11px; }
.playtest-card-heading strong { font-size: 0.78rem; }
.playtest-card-heading span { color: var(--muted); font-size: 0.63rem; font-variant-numeric: tabular-nums; }
.stick-scope { --raw-x: 50%; --raw-y: 50%; --output-x: 50%; --output-y: 50%; --inner-size: 0%; --outer-size: 100%; position: relative; width: min(100%, 174px); margin: auto; aspect-ratio: 1; overflow: hidden; border: 1px solid var(--control-line); border-radius: 50%; background: #080e1c; }
.stick-axis { position: absolute; background: rgba(171, 181, 204, 0.16); }
.stick-axis-x { top: 50%; right: 10%; left: 10%; height: 1px; }
.stick-axis-y { top: 10%; bottom: 10%; left: 50%; width: 1px; }
.stick-ring { position: absolute; top: 50%; left: 50%; border: 1px solid; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
.stick-ring-outer { width: var(--outer-size); height: var(--outer-size); border-color: rgba(89, 199, 255, 0.62); }
.stick-ring-inner { width: var(--inner-size); height: var(--inner-size); border-color: rgba(255, 124, 159, 0.8); background: rgba(255, 124, 159, 0.05); }
.stick-dot { position: absolute; top: 50%; left: 50%; border-radius: 50%; transform: translate(-50%, -50%); transition: left 45ms linear, top 45ms linear; }
.stick-dot-raw { top: var(--raw-y); left: var(--raw-x); z-index: 2; width: 15px; height: 15px; border: 3px solid var(--amber); background: transparent; box-shadow: 0 0 0 2px rgba(246, 199, 109, 0.12); }
.stick-dot-output { top: var(--output-y); left: var(--output-x); z-index: 3; width: 8px; height: 8px; border: 2px solid #080e1c; background: var(--blue); box-shadow: 0 0 0 2px rgba(106, 167, 255, 0.3); }
.trigger-playtest { display: flex; flex-direction: column; }
.trigger-meter { display: grid; grid-template-columns: 28px minmax(120px, 1fr) 88px; align-items: center; gap: 9px; margin-top: 12px; }
.trigger-meter > span { color: var(--ink); font-size: 0.7rem; font-weight: 850; }
.trigger-meter output { color: var(--muted); font-size: 0.65rem; font-variant-numeric: tabular-nums; text-align: right; }
.trigger-track { --raw: 0%; --output: 0%; --threshold: 35%; position: relative; height: 28px; overflow: hidden; border: 1px solid var(--control-line); border-radius: 8px; background: #080e1c; }
.trigger-bar { position: absolute; left: 0; height: 7px; border-radius: 0 4px 4px 0; transition: width 45ms linear; }
.trigger-bar-raw { top: 4px; width: var(--raw); background: var(--amber); }
.trigger-bar-output { bottom: 4px; width: var(--output); background: var(--blue); }
.trigger-threshold { position: absolute; top: 0; bottom: 0; left: var(--threshold); width: 2px; background: var(--rose); transform: translateX(-1px); }
.playtest-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 16px; color: var(--muted); font-size: 0.61rem; }
.playtest-legend span { display: inline-flex; align-items: center; gap: 5px; }
.playtest-legend i { width: 8px; height: 8px; border-radius: 50%; }
.legend-raw { background: var(--amber); }
.legend-output { background: var(--blue); }
.legend-threshold { background: var(--rose); }
.controller-hotspots button.pressed { z-index: 4; color: #071018; border-color: var(--mint); background: var(--mint); box-shadow: 0 0 0 5px rgba(74, 222, 165, 0.2), 0 8px 20px rgba(0, 0, 0, 0.42); }
.analog-grid, .feedback-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.subpanel { padding: 19px; border: 1px solid var(--line-soft); border-radius: 16px; background: var(--surface-2); }
.subpanel-heading { align-items: flex-start; margin-bottom: 16px; }
@ -502,6 +541,8 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.profile-list { grid-template-columns: 1fr; }
.mapping-grid, .mapping-grid.compact { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
.controller-mapper { grid-template-columns: 1fr; }
.playtest-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); }
.trigger-playtest { grid-column: 1 / -1; }
.mapping-inspector { min-height: 270px; }
.editor-header { align-items: flex-start; }
}
@ -519,6 +560,8 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.header-actions { justify-content: flex-start; }
.section-nav { top: 8px; }
.builtin-actions, .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; }
.playtest-grid { grid-template-columns: 1fr; }
.trigger-playtest { grid-column: auto; }
.panel-heading > p, .subpanel-heading span { text-align: left; }
.macro-picker { flex-direction: column; }
.macro-steps-heading { align-items: stretch; flex-direction: column; }
@ -533,6 +576,8 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
.workspace { width: min(100% - 16px, 480px); }
.sidebar, .panel, .editor-header { border-radius: 17px; }
.sidebar, .panel { padding: 18px; }
.trigger-meter { grid-template-columns: 25px minmax(100px, 1fr); }
.trigger-meter output { grid-column: 2; text-align: left; }
.profile-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mapping-grid, .mapping-grid.compact, .number-grid, .step-grid { grid-template-columns: 1fr; }
.button { flex: 1 1 auto; min-height: 44px; }

View file

@ -19,7 +19,7 @@
</div>
<div class="topbar-meta">
<p>Map · tune · automate</p>
<div class="connection" id="connectionState" data-state="loading">
<div class="connection" id="connectionState" data-state="loading" role="status" aria-live="polite">
<span class="connection-dot"></span>
<span id="connectionText">Connecting to adapter…</span>
</div>
@ -151,6 +151,77 @@
</div>
<p>Values use the controller's normalized integer range.</p>
</div>
<div class="playtest-panel" id="playtestPanel" data-state="waiting">
<div class="playtest-heading">
<div>
<p class="eyebrow">Live playtest</p>
<h4 id="playtestTitle">Waiting for controller input</h4>
</div>
<span class="playtest-status" id="playtestStatus">Waiting</span>
</div>
<p class="playtest-help" id="playtestHelp">
Select a connected controller to compare its raw input with this draft.
</p>
<div class="playtest-grid">
<article class="playtest-card">
<div class="playtest-card-heading">
<strong>Left stick</strong>
<span id="playtestLeftValues">0, 0 → 0, 0</span>
</div>
<div class="stick-scope" data-playtest-stick="left">
<span class="stick-ring stick-ring-outer"></span>
<span class="stick-ring stick-ring-inner"></span>
<span class="stick-axis stick-axis-x"></span>
<span class="stick-axis stick-axis-y"></span>
<i class="stick-dot stick-dot-raw" title="Raw input"></i>
<i class="stick-dot stick-dot-output" title="Draft output"></i>
</div>
</article>
<article class="playtest-card">
<div class="playtest-card-heading">
<strong>Right stick</strong>
<span id="playtestRightValues">0, 0 → 0, 0</span>
</div>
<div class="stick-scope" data-playtest-stick="right">
<span class="stick-ring stick-ring-outer"></span>
<span class="stick-ring stick-ring-inner"></span>
<span class="stick-axis stick-axis-x"></span>
<span class="stick-axis stick-axis-y"></span>
<i class="stick-dot stick-dot-raw" title="Raw input"></i>
<i class="stick-dot stick-dot-output" title="Draft output"></i>
</div>
</article>
<article class="playtest-card trigger-playtest">
<div class="playtest-card-heading">
<strong>Triggers</strong>
<span>Raw → draft output</span>
</div>
<div class="trigger-meter" data-playtest-trigger="left">
<span id="playtestLeftTriggerLabel">LT</span>
<div class="trigger-track">
<i class="trigger-bar trigger-bar-raw"></i>
<i class="trigger-bar trigger-bar-output"></i>
<b class="trigger-threshold" title="Digital threshold"></b>
</div>
<output>0 → 0</output>
</div>
<div class="trigger-meter" data-playtest-trigger="right">
<span id="playtestRightTriggerLabel">RT</span>
<div class="trigger-track">
<i class="trigger-bar trigger-bar-raw"></i>
<i class="trigger-bar trigger-bar-output"></i>
<b class="trigger-threshold" title="Digital threshold"></b>
</div>
<output>0 → 0</output>
</div>
<div class="playtest-legend">
<span><i class="legend-raw"></i>Raw</span>
<span><i class="legend-output"></i>Draft output</span>
<span><i class="legend-threshold"></i>Digital threshold</span>
</div>
</article>
</div>
</div>
<div class="analog-grid" id="analogFields"></div>
</section>
@ -201,6 +272,7 @@
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/playtest.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>

View file

@ -1,5 +1,7 @@
"use strict";
const PROFILE_OWNER_STORAGE_KEY = "switch-pico.profile-owner";
const state = {
schema: null,
identities: [],
@ -12,6 +14,11 @@ const state = {
selectedMacro: 0,
token: "",
busy: false,
adapterConnected: false,
playtestRequestActive: false,
playtestTimer: 0,
libraryRequestActive: false,
libraryTimer: 0,
};
const elements = {
@ -41,6 +48,14 @@ const elements = {
macroControls: document.querySelector("#macroControls"),
macroSteps: document.querySelector("#macroSteps"),
macroStepsTitle: document.querySelector("#macroStepsTitle"),
playtestPanel: document.querySelector("#playtestPanel"),
playtestTitle: document.querySelector("#playtestTitle"),
playtestStatus: document.querySelector("#playtestStatus"),
playtestHelp: document.querySelector("#playtestHelp"),
playtestLeftValues: document.querySelector("#playtestLeftValues"),
playtestRightValues: document.querySelector("#playtestRightValues"),
playtestLeftTriggerLabel: document.querySelector("#playtestLeftTriggerLabel"),
playtestRightTriggerLabel: document.querySelector("#playtestRightTriggerLabel"),
refresh: document.querySelector("#refreshButton"),
resetDraft: document.querySelector("#resetDraftButton"),
activate: document.querySelector("#activateButton"),
@ -91,13 +106,161 @@ function canonical(value) {
return JSON.stringify(value);
}
const {
transformStick,
transformTrigger,
stickCoordinates,
triggerPercent,
} = ProfilePlaytestMath;
function updateStickPlaytest(side, raw, output, config) {
const scope = document.querySelector(`[data-playtest-stick="${side}"]`);
const rawPosition = stickCoordinates(raw);
const outputPosition = stickCoordinates(output);
scope.style.setProperty("--raw-x", `${rawPosition.left}%`);
scope.style.setProperty("--raw-y", `${rawPosition.top}%`);
scope.style.setProperty("--output-x", `${outputPosition.left}%`);
scope.style.setProperty("--output-y", `${outputPosition.top}%`);
scope.style.setProperty(
"--inner-size",
`${Math.max(0, Math.min(100, config.inner_deadzone / 32767 * 100))}%`
);
scope.style.setProperty(
"--outer-size",
`${Math.max(0, Math.min(100, config.outer_saturation / 32767 * 100))}%`
);
const values = `${raw.x}, ${raw.y}${output.x}, ${output.y}`;
(side === "left" ? elements.playtestLeftValues : elements.playtestRightValues)
.textContent = values;
}
function updateTriggerPlaytest(side, raw, output, config) {
const meter = document.querySelector(`[data-playtest-trigger="${side}"]`);
const track = meter.querySelector(".trigger-track");
track.style.setProperty("--raw", `${triggerPercent(raw)}%`);
track.style.setProperty("--output", `${triggerPercent(output)}%`);
track.style.setProperty(
"--threshold",
`${config.digital_threshold / 65535 * 100}%`
);
meter.querySelector("output").textContent = `${raw}${output}`;
}
function clearPlaytest(message, stateName = "waiting") {
elements.playtestPanel.dataset.state = stateName;
elements.playtestStatus.textContent =
stateName === "error" ? "Unavailable" : "Waiting";
elements.playtestTitle.textContent =
stateName === "error" ? "Live input unavailable" : "Waiting for controller input";
elements.playtestHelp.textContent = message;
elements.controllerHotspots.querySelectorAll(".pressed")
.forEach((button) => button.classList.remove("pressed"));
}
function renderPlaytest(sample) {
if (!sample.connected) {
clearPlaytest(
"Connect or move a controller to compare its raw input with this draft."
);
return;
}
const rawLeft = sample.left_stick;
const rawRight = sample.right_stick;
const outputLeft = transformStick(rawLeft, state.profile.sticks.left);
const outputRight = transformStick(rawRight, state.profile.sticks.right);
const outputLeftTrigger = transformTrigger(
sample.triggers.left, state.profile.triggers.left
);
const outputRightTrigger = transformTrigger(
sample.triggers.right, state.profile.triggers.right
);
updateStickPlaytest(
"left", rawLeft, outputLeft, state.profile.sticks.left
);
updateStickPlaytest(
"right", rawRight, outputRight, state.profile.sticks.right
);
updateTriggerPlaytest(
"left", sample.triggers.left, outputLeftTrigger,
state.profile.triggers.left
);
updateTriggerPlaytest(
"right", sample.triggers.right, outputRightTrigger,
state.profile.triggers.right
);
const style = sample.controller?.style || currentControllerStyle();
elements.playtestLeftTriggerLabel.textContent =
controlLabel("left_trigger", style);
elements.playtestRightTriggerLabel.textContent =
controlLabel("right_trigger", style);
const pressed = new Set(sample.buttons);
if (sample.triggers.left > 512) pressed.add("left_trigger");
if (sample.triggers.right > 512) pressed.add("right_trigger");
elements.controllerHotspots.querySelectorAll("[data-controller-button]")
.forEach((button) => {
button.classList.toggle(
"pressed", pressed.has(button.dataset.controllerButton)
);
});
elements.playtestPanel.dataset.state = "live";
elements.playtestStatus.textContent = "Live";
elements.playtestTitle.textContent = sample.label || "Connected controller";
elements.playtestHelp.textContent =
"Yellow is raw input; blue is the output produced by this unsaved draft.";
}
async function pollPlaytest() {
window.clearTimeout(state.playtestTimer);
if (
document.hidden || state.playtestRequestActive ||
!state.schema || !state.profile
) {
state.playtestTimer = window.setTimeout(pollPlaytest, 250);
return;
}
const identityIndex = state.identityIndex;
const profileIndex = state.profileIndex;
state.playtestRequestActive = true;
try {
const sample = await api(
`/api/profiles/${identityIndex}/${profileIndex + 1}/playtest`
);
if (
identityIndex === state.identityIndex &&
profileIndex === state.profileIndex
) {
renderPlaytest(sample);
}
} catch (error) {
if (
identityIndex === state.identityIndex &&
profileIndex === state.profileIndex
) {
clearPlaytest(
`${error.message}. Flash current firmware to enable live playtest.`,
"error"
);
}
} finally {
state.playtestRequestActive = false;
state.playtestTimer = window.setTimeout(pollPlaytest, 75);
}
}
function isDirty() {
return state.profile !== null && canonical(state.profile) !== state.original;
}
function setConnection(mode, text) {
state.adapterConnected = mode === "ready";
elements.connection.dataset.state = mode;
elements.connectionText.textContent = text;
if (state.profile) {
elements.save.disabled =
!state.adapterConnected || state.busy || !isDirty();
elements.activate.disabled =
!state.adapterConnected || state.busy || state.active;
}
}
let toastTimer = 0;
@ -129,8 +292,10 @@ async function api(path, options = {}) {
function setBusy(busy) {
state.busy = busy;
elements.save.disabled = busy || !isDirty();
elements.activate.disabled = busy || state.active;
elements.save.disabled =
busy || !state.adapterConnected || !isDirty();
elements.activate.disabled =
busy || !state.adapterConnected || state.active;
elements.resetDraft.disabled = busy;
elements.refresh.disabled = busy;
elements.identity.disabled = busy;
@ -157,24 +322,114 @@ function setBusy(busy) {
function updateDirtyState() {
const dirty = isDirty();
elements.dirtyBadge.hidden = !dirty;
elements.save.disabled = state.busy || !dirty;
elements.save.disabled =
!state.adapterConnected || state.busy || !dirty;
}
function confirmDiscard() {
return !isDirty() || window.confirm("Discard the unsaved changes to this profile?");
}
function currentOwner() {
return state.identities.find(
(entry) => entry.index === state.identityIndex
) || null;
}
function storedOwnerKey() {
try {
return window.localStorage.getItem(PROFILE_OWNER_STORAGE_KEY);
} catch {
return null;
}
}
function persistOwnerKey(key) {
try {
window.localStorage.setItem(PROFILE_OWNER_STORAGE_KEY, key);
} catch {
// Storage can be unavailable in private or hardened browser contexts.
}
}
function identitySignature(identities) {
return identities.map((entry) => (
`${entry.index}:${entry.key}:${entry.label}:` +
`${entry.controller.model}:${entry.controller.style}`
)).join("|");
}
function syncLibraryMetadata(identities, restoreStoredOwner = false) {
const oldOwner = currentOwner();
const oldSignature = identitySignature(state.identities);
const preferredKey = (
restoreStoredOwner ? storedOwnerKey() : oldOwner?.key
) || storedOwnerKey();
state.identities = identities;
const nextOwner = (
identities.find((entry) => entry.key === preferredKey) ||
identities[0] ||
null
);
state.identityIndex = nextOwner?.index ?? 0;
if (nextOwner) persistOwnerKey(nextOwner.key);
const ownerChanged = oldOwner?.key !== nextOwner?.key;
const identitiesChanged =
oldSignature !== identitySignature(state.identities);
const activeChanged =
oldOwner?.active_profile !== nextOwner?.active_profile;
if (state.schema && (identitiesChanged || ownerChanged)) {
renderIdentities();
}
if (state.schema && (activeChanged || ownerChanged)) {
renderProfileList();
}
if (state.profile) {
state.active =
nextOwner?.active_profile === state.profileIndex + 1;
elements.activeBadge.hidden = !state.active;
elements.activate.disabled =
!state.adapterConnected || state.busy || state.active;
}
return ownerChanged;
}
async function pollLibraryMetadata() {
window.clearTimeout(state.libraryTimer);
if (
document.hidden || state.busy ||
state.libraryRequestActive || !state.schema
) {
state.libraryTimer =
window.setTimeout(pollLibraryMetadata, 500);
return;
}
state.libraryRequestActive = true;
try {
const payload = await api("/api/profiles");
syncLibraryMetadata(payload.identities);
setConnection("ready", "Adapter connected");
} catch {
setConnection("error", "Adapter disconnected");
} finally {
state.libraryRequestActive = false;
state.libraryTimer =
window.setTimeout(pollLibraryMetadata, 750);
}
}
function renderIdentities() {
elements.identity.innerHTML = state.identities
.map((entry) => `<option value="${entry.index}">${escapeHtml(entry.label)}</option>`)
.join("");
elements.identity.value = String(state.identityIndex);
const owner = state.identities[state.identityIndex];
const owner = currentOwner();
elements.profileOwner.textContent = owner ? owner.label : "No controller";
}
function renderProfileList() {
const owner = state.identities[state.identityIndex];
const owner = currentOwner();
elements.profileList.innerHTML = Array.from({ length: state.schema.profile_capacity }, (_, index) => {
const selected = index === state.profileIndex;
const active = owner && owner.active_profile === index + 1;
@ -587,7 +842,8 @@ function renderMacro() {
function renderEditor() {
elements.profileTitle.textContent = `Profile ${state.profileIndex + 1}`;
elements.activeBadge.hidden = !state.active;
elements.activate.disabled = state.busy || state.active;
elements.activate.disabled =
!state.adapterConnected || state.busy || state.active;
renderIdentities();
renderProfileList();
renderButtonMap();
@ -601,6 +857,7 @@ function renderEditor() {
}
async function loadProfile() {
clearPlaytest("Loading the selected controller profile.");
setBusy(true);
elements.form.hidden = true;
elements.loading.hidden = false;
@ -627,10 +884,7 @@ async function loadLibrary() {
setBusy(true);
try {
const payload = await api("/api/profiles");
state.identities = payload.identities;
if (!state.identities.some((entry) => entry.index === state.identityIndex)) {
state.identityIndex = 0;
}
syncLibraryMetadata(payload.identities, true);
await loadProfile();
} catch (error) {
setConnection("error", "Adapter unavailable");
@ -716,6 +970,8 @@ elements.identity.addEventListener("change", async () => {
return;
}
state.identityIndex = Number(elements.identity.value);
const owner = currentOwner();
if (owner) persistOwnerKey(owner.key);
state.profileIndex = 0;
await loadProfile();
});
@ -813,6 +1069,8 @@ async function start() {
state.schema = schema;
state.token = schema.mutation_token;
await loadLibrary();
pollPlaytest();
pollLibraryMetadata();
} catch (error) {
setConnection("error", "Editor failed to start");
elements.loading.querySelector("p").textContent = error.message;

View file

@ -0,0 +1,112 @@
"use strict";
(function publishProfilePlaytestMath(root) {
function applyCurveQ16(input, curve) {
if (input === 0 || input === 65536 || curve === 256) return input;
const denominator = curve * (65536 - input) + 256 * input;
return Math.min(
65536,
Math.floor(
(input * 256 * 65536 + Math.floor(denominator / 2)) /
denominator
)
);
}
function transformStickAxis(adjusted, magnitude, response, invert) {
if (adjusted === 0 || magnitude === 0 || response === 0) return 0;
const negative = (adjusted < 0) !== invert;
const limit = negative ? 32768 : 32767;
const denominator = magnitude * 65536;
const output = Math.min(
limit,
Math.floor(
(
Math.abs(adjusted) * limit * response +
Math.floor(denominator / 2)
) / denominator
)
);
return negative ? -output : output;
}
function transformStick(input, config) {
if (
config.center_x === 0 && config.center_y === 0 &&
config.inner_deadzone === 0 && config.outer_saturation === 32767 &&
config.curve_q8_8 === 256 && !config.invert_x && !config.invert_y
) {
return { x: input.x, y: input.y };
}
const x = Math.max(-32768, Math.min(32767, input.x - config.center_x));
const y = Math.max(-32768, Math.min(32767, input.y - config.center_y));
if (config.outer_saturation <= config.inner_deadzone) {
return { x: 0, y: 0 };
}
const magnitude = Math.max(Math.abs(x), Math.abs(y));
let response = 0;
if (magnitude >= config.outer_saturation) {
response = 65536;
} else if (magnitude > config.inner_deadzone) {
const range = config.outer_saturation - config.inner_deadzone;
const normalized = Math.floor(
(
(magnitude - config.inner_deadzone) * 65536 +
Math.floor(range / 2)
) / range
);
response = applyCurveQ16(normalized, config.curve_q8_8);
}
return {
x: transformStickAxis(x, magnitude, response, config.invert_x),
y: transformStickAxis(y, magnitude, response, config.invert_y),
};
}
function transformTrigger(input, config) {
if (
config.lower_deadzone === 0 &&
config.upper_saturation === 65535 &&
config.curve_q8_8 === 256
) return input;
if (config.upper_saturation <= config.lower_deadzone) return 0;
if (input <= config.lower_deadzone) return 0;
if (input >= config.upper_saturation) return 65535;
const range = config.upper_saturation - config.lower_deadzone;
const offset = input - config.lower_deadzone;
if (config.curve_q8_8 === 256) {
return Math.floor(
(offset * 65535 + Math.floor(range / 2)) / range
);
}
const normalized = Math.floor(
(offset * 65536 + Math.floor(range / 2)) / range
);
return Math.floor(
(applyCurveQ16(normalized, config.curve_q8_8) * 65535 + 32768) /
65536
);
}
function stickPosition(value) {
return Math.max(3, Math.min(97, 50 + value / 32768 * 47));
}
function stickCoordinates(input) {
return {
left: stickPosition(input.x),
top: stickPosition(input.y),
};
}
function triggerPercent(value) {
return Math.max(0, Math.min(100, value / 65535 * 100));
}
root.ProfilePlaytestMath = Object.freeze({
transformStick,
transformTrigger,
stickCoordinates,
triggerPercent,
});
})(globalThis);

View file

@ -785,6 +785,19 @@ void test_rejections() {
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
g_last_snapshot_generation[0] = 0;
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.active && playtest.state_generation != 0 &&
playtest.state.motion_sample_count == 3,
"playtest snapshot did not expose current raw input");
bluepad32_input_backend_report_sent(0);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"playtest snapshot consumed report-path motion");
bluepad32_input_backend_playtest_snapshot(4, &playtest);
require(!playtest.active && playtest.state_generation == 0,
"playtest snapshot accepted slot 4");
require(!read_controller_state(4, &snapshot),
"public snapshot must reject slot 4");
bluepad32_input_backend_report_sent(4);
@ -2136,6 +2149,16 @@ void test_protocol_neutral_analog_state() {
peer_state.left_trigger == 16399 &&
peer_state.right_trigger == 49199,
"slot 0 digital trigger fallback changed slot 1");
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.button_capture,
"touchpad/capture input did not reach the logical capture button");
input.gamepad.misc_buttons = 0;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
!state.button_capture,
"released touchpad/capture input remained pressed");
}
void test_host_rumble_mode_duration() {

View file

@ -0,0 +1,65 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
require(path.join(
__dirname,
"..",
"src",
"switch_pico_bridge",
"web",
"profile_playtest.js"
));
const {
transformStick,
transformTrigger,
stickCoordinates,
triggerPercent,
} = globalThis.ProfilePlaytestMath;
const defaultStick = {
center_x: 0,
center_y: 0,
inner_deadzone: 0,
outer_saturation: 32767,
curve_q8_8: 256,
invert_x: false,
invert_y: false,
};
const defaultTrigger = {
lower_deadzone: 0,
upper_saturation: 65535,
curve_q8_8: 256,
};
assert.deepEqual(
transformStick({ x: -1234, y: 2345 }, defaultStick),
{ x: -1234, y: 2345 }
);
assert.ok(stickCoordinates({ x: 0, y: 20000 }).top > 50,
"positive/down input must render below center");
assert.ok(stickCoordinates({ x: 0, y: -20000 }).top < 50,
"negative/up input must render above center");
assert.ok(stickCoordinates({ x: -20000, y: 0 }).left < 50,
"negative/left input must render left of center");
assert.deepEqual(
transformStick(
{ x: -1234, y: 2345 },
{ ...defaultStick, inner_deadzone: 5000 }
),
{ x: 0, y: 0 }
);
assert.deepEqual(
transformStick(
{ x: -30000, y: 30000 },
{ ...defaultStick, curve_q8_8: 512 }
),
{ x: -27665, y: 27664 }
);
assert.equal(transformTrigger(65000, defaultTrigger), 65000);
assert.ok(triggerPercent(65000) > 99,
"nonzero trigger input must produce a visible bar");
assert.equal(triggerPercent(0), 0);
assert.equal(triggerPercent(65535), 100);

View file

@ -112,6 +112,14 @@ class FakeDevice:
self.pending_profile_mutation: tuple[int, bytes, int] | None = None
self.profile_transaction_pending_reads = 0
self.profile_status_responses: list[tuple[int, int]] = []
self.playtest_connected = True
self.playtest_slot = 1
self.playtest_connection_generation = 17
self.playtest_state_generation = 93
self.playtest_button_mask = 0x9001
self.playtest_sticks = (-1234, 2345, -30000, 30000)
self.playtest_triggers = (123, 65000)
self.playtest_motion = (1, -2, 3, -4, 5, -6)
def _pairing_payload(self) -> bytes:
payload = bytearray([len(self.records), 0, 0, 0])
@ -157,6 +165,27 @@ class FakeDevice:
zlib.crc32(stored) & 0xFFFFFFFF,
)
def _profile_playtest_payload(self) -> tuple[bytes, int]:
payload = bytearray(config_manager.PROFILE_PLAYTEST_SIZE)
payload[1] = 0xFF
if not self.playtest_connected:
return bytes(payload), 0
flags = 0x03 if self.playtest_motion is not None else 0x01
payload[0] = flags
payload[1] = self.playtest_slot
struct.pack_into("<HII", payload, 2, self.playtest_button_mask,
self.playtest_connection_generation,
self.playtest_state_generation)
payload[12:26] = self.stable_identity.to_bytes()
struct.pack_into(
"<hhhhHH", payload, 26, *self.playtest_sticks,
*self.playtest_triggers
)
payload[38] = 1 if self.playtest_motion is not None else 0
if self.playtest_motion is not None:
struct.pack_into("<hhhhhh", payload, 40, *self.playtest_motion)
return bytes(payload), flags
def _queue_profile_mutation(self, operation: int, payload: bytes) -> None:
assert len(payload) == 19
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
@ -296,6 +325,15 @@ class FakeDevice:
if self.bad_profile_response_crc:
response[-1] ^= 1
return bytes(response)
if request == config_manager.OP_PROFILE_PLAYTEST:
payload, flags = self._profile_playtest_payload()
return make_response(
request,
payload,
flags=flags,
schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION,
generation=self.playtest_state_generation,
)
if request == config_manager.OP_PROFILE_TRANSACTION_STATUS:
if self.profile_transaction_status == config_manager.STATUS_PENDING:
if self.profile_transaction_pending_reads:
@ -1418,6 +1456,61 @@ def test_profile_list_select_read_and_chunked_commit() -> None:
)
def test_profile_playtest_decodes_raw_controller_state() -> None:
device = FakeDevice()
playtest = config_manager.read_profile_playtest(device)
assert playtest == config_manager.ProfilePlaytest(
connected=True,
slot_index=1,
connection_generation=17,
state_generation=93,
identity=device.stable_identity,
button_mask=0x9001,
left_stick=(-1234, 2345),
right_stick=(-30000, 30000),
triggers=(123, 65000),
motion=(1, -2, 3, -4, 5, -6),
)
assert playtest.to_json_object()["buttons"] == [
"south",
"dpad_up",
"dpad_right",
]
device.playtest_connected = False
disconnected = config_manager.read_profile_playtest(device)
assert disconnected == config_manager.ProfilePlaytest(
connected=False,
slot_index=None,
connection_generation=0,
state_generation=0,
identity=None,
button_mask=0,
left_stick=(0, 0),
right_stick=(0, 0),
triggers=(0, 0),
motion=None,
)
device.playtest_connected = True
payload, flags = device._profile_playtest_payload()
malformed = bytearray(payload)
malformed[38] = 0
envelope = config_manager.parse_response(
make_response(
config_manager.OP_PROFILE_PLAYTEST,
malformed,
flags=flags,
schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION,
),
config_manager.OP_PROFILE_PLAYTEST,
)
with pytest.raises(
config_manager.ConfigManagerError,
match="invalid connected playtest payload",
):
config_manager.parse_profile_playtest(envelope)
def test_profile_reset_and_activate_wait_for_correlated_transactions(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -115,6 +115,18 @@ def test_prepare_patches_copy_and_preserves_pristine_source(
assert source_status(source) == ""
def test_prepare_applies_patch_inside_parent_repository(
bluepad32_fixture: tuple[Path, Path, Path, Path],
) -> None:
root, source, patch, output = bluepad32_fixture
run_git(root, "init")
prepare_bluepad32(source, patch, output)
assert (output / "test.txt").read_text(encoding="utf-8") == "line 1\nline 2\n"
assert source_status(source) == ""
def test_prepare_replaces_existing_output_idempotently(
bluepad32_fixture: tuple[Path, Path, Path, Path],
) -> None:

View file

@ -0,0 +1,16 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_profile_playtest_math() -> None:
root = Path(__file__).resolve().parents[1]
node = shutil.which("node")
assert node is not None, "Node.js is required for Profile Studio tests"
_ = subprocess.run(
[node, str(root / "tests" / "profile_playtest_test.js")],
check=True,
cwd=root,
)

View file

@ -150,6 +150,9 @@ def test_editor_identifies_connected_controller_artwork(
"DualSense · 15:16",
"Xbox · 50:60",
]
assert [identity["key"] for identity in listing["identities"]] == [
identity.to_bytes().hex() for identity in device.profile_identities
]
def test_editor_reads_writes_and_activates_profiles_atomically(
@ -160,11 +163,32 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert listing["identities"][1]["active_profile"] == 2
assert listing["identities"][1]["key"] == (
device.stable_identity.to_bytes().hex()
)
assert listing["identities"][1]["controller"] == {
"model": "Xbox controller",
"style": "xbox",
}
status, playtest = request_json(
f"{base_url}/api/profiles/1/8/playtest"
)
assert status == 200
assert playtest["connected"] is True
assert playtest["label"] == "Xbox · 50:60"
assert playtest["controller"] == {
"model": "Xbox controller",
"style": "xbox",
}
assert playtest["buttons"] == [
"south",
"dpad_up",
"dpad_right",
]
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
assert playtest["triggers"] == {"left": 123, "right": 65000}
status, selected = request_json(f"{base_url}/api/profiles/1/8")
assert status == 200
assert selected["active"] is False

View file

@ -14,6 +14,8 @@ ConfigurationServiceSnapshot current_configuration{};
ProfileServiceListSnapshot current_profile_list{};
ProfileServiceSelectedSnapshot current_profile_selected{};
ProfileServiceTransactionSnapshot current_profile_transaction{};
Bluepad32PlaytestSnapshot current_playtest[
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe;
uint8_t current_capabilities =
USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE |
@ -64,6 +66,11 @@ void write_u16(std::vector<uint8_t>* output, size_t offset,
(*output)[offset + 1] = static_cast<uint8_t>(value >> 8);
}
uint16_t read_u16(const std::vector<uint8_t>& input, size_t offset) {
return static_cast<uint16_t>(input[offset]) |
static_cast<uint16_t>(input[offset + 1] << 8);
}
void write_u32(std::vector<uint8_t>* output, size_t offset,
uint32_t value) {
(*output)[offset] = static_cast<uint8_t>(value);
@ -483,6 +490,56 @@ void test_profile_vendor_requests() {
control_payload[kResponseHeaderSize + 3] == 1,
"selected profile response was not encoded");
current_playtest[2] = {};
current_playtest[2].active = true;
current_playtest[2].connection_generation = 0x11223344;
current_playtest[2].state_generation = 0x55667788;
current_playtest[2].identity = expected_identity;
current_playtest[2].physical_button_mask = 0x8001;
current_playtest[2].state.left_stick_x = -1234;
current_playtest[2].state.left_stick_y = 2345;
current_playtest[2].state.right_stick_x = INT16_MIN;
current_playtest[2].state.right_stick_y = INT16_MAX;
current_playtest[2].state.left_trigger = 123;
current_playtest[2].state.right_trigger = 65000;
current_playtest[2].state.motion_sample_count = 1;
current_playtest[2].state.motion_samples[0] =
{1, -2, 3, -4, 5, -6};
request = setup_request(
Operation::kProfilePlaytest, TUSB_DIR_IN,
kMaximumResponseSize);
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() ==
kResponseHeaderSize + kProfilePlaytestPayloadSize &&
control_payload[5] ==
static_cast<uint8_t>(Operation::kProfilePlaytest) &&
control_payload[7] == 3 &&
control_payload[10] ==
kProfilePlaytestSchemaVersion &&
control_payload[kResponseHeaderSize] == 3 &&
control_payload[kResponseHeaderSize + 1] == 2 &&
read_u16(control_payload, kResponseHeaderSize + 2) ==
0x8001 &&
read_u32(control_payload, kResponseHeaderSize + 4) ==
0x11223344 &&
read_u32(control_payload, kResponseHeaderSize + 8) ==
0x55667788 &&
static_cast<int16_t>(read_u16(
control_payload, kResponseHeaderSize + 26)) ==
-1234 &&
read_u16(control_payload, kResponseHeaderSize + 36) ==
65000 &&
static_cast<int16_t>(read_u16(
control_payload, kResponseHeaderSize + 50)) == -6,
"profile playtest response lost live controller state");
current_playtest[2].active = false;
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload[kResponseHeaderSize] == 0 &&
control_payload[kResponseHeaderSize + 1] == 0xff,
"disconnected profile playtest was not encoded");
current_profile_transaction = {};
current_profile_transaction.metadata.state =
ProfileServiceState::kReady;
@ -754,6 +811,11 @@ void bluepad32_input_backend_pairing_snapshot(
*out = current_pairings;
}
void bluepad32_input_backend_playtest_snapshot(
uint8_t slot, Bluepad32PlaytestSnapshot* out) {
*out = current_playtest[slot];
}
void bluepad32_input_backend_diagnostics(
Bluepad32BackendDiagnostics* out) {
*out = current_diagnostics;

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
@ -22,10 +23,16 @@ def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path, Path]:
)
def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
def _run(
command: list[str],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd,
env=env,
capture_output=True,
text=True,
check=False,
@ -94,6 +101,12 @@ def _copy_and_patch(source_path: Path, patch_path: Path, output_path: Path) -> N
result = _run(
["git", "apply", "--no-index", str(patch_path.resolve())],
cwd=staging_path,
env={
**os.environ,
"GIT_CEILING_DIRECTORIES": str(
staging_path.resolve().parent
),
},
)
if result.returncode != 0:
detail = result.stderr.strip() or "git apply failed"