From 890f47ab806b76572d0fa5107e62e03430c73431 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 4 Sep 2026 19:37:58 -0600 Subject: [PATCH] Add live profile playtest and input fixes --- README.md | 6 +- patches/bluepad32-sdl3-imu.patch | 8 +- .../input/bluepad32_input_backend.cpp | 21 ++ src/firmware/input/bluepad32_input_backend.h | 15 +- .../usb/usb_configuration_management.cpp | 84 ++++++ .../usb/usb_configuration_management.h | 6 + src/switch_pico_bridge/config_manager.py | 107 +++++++ src/switch_pico_bridge/profile_web.py | 37 ++- src/switch_pico_bridge/web/profile_editor.css | 45 +++ .../web/profile_editor.html | 74 ++++- src/switch_pico_bridge/web/profile_editor.js | 278 +++++++++++++++++- .../web/profile_playtest.js | 112 +++++++ tests/bluepad32_backend_lifecycle_test.cpp | 23 ++ tests/profile_playtest_test.js | 65 ++++ tests/test_config_manager.py | 93 ++++++ tests/test_prepare_bluepad32.py | 12 + tests/test_profile_playtest_js.py | 16 + tests/test_profile_web.py | 24 ++ tests/usb_configuration_management_test.cpp | 62 ++++ tools/prepare_bluepad32.py | 15 +- 20 files changed, 1083 insertions(+), 20 deletions(-) create mode 100644 src/switch_pico_bridge/web/profile_playtest.js create mode 100644 tests/profile_playtest_test.js create mode 100644 tests/test_profile_playtest_js.py diff --git a/README.md b/README.md index 2dd1db8..a4fb6d0 100644 --- a/README.md +++ b/README.md @@ -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 15–30 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. diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index 3b03c1f..bf48e82 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -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++) { diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 0ccab0f..9006a93 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -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)) { diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index fab3f2b..f96ca9b 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -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, diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index a4e7628..c100579 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -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(snapshot.state.left_stick_x)); + write_u16( + &payload[28], + static_cast(snapshot.state.left_stick_y)); + write_u16( + &payload[30], + static_cast(snapshot.state.right_stick_x)); + write_u16( + &payload[32], + static_cast(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(motion.accel_x)); + write_u16(&payload[42], + static_cast(motion.accel_y)); + write_u16(&payload[44], + static_cast(motion.accel_z)); + write_u16(&payload[46], + static_cast(motion.gyro_x)); + write_u16(&payload[48], + static_cast(motion.gyro_y)); + write_u16(&payload[50], + static_cast(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); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 0477795..5fe2b11 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -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); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 2231823..060f6ff 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -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(" 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) diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 8c3e262..a71258a 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -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, diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index dce0cfd..9408924 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -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; } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 20edbbe..7066bda 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -19,7 +19,7 @@

Map · tune · automate

-
+
Connecting to adapter…
@@ -151,6 +151,77 @@

Values use the controller's normalized integer range.

+
+
+
+

Live playtest

+

Waiting for controller input

+
+ Waiting +
+

+ Select a connected controller to compare its raw input with this draft. +

+
+
+
+ Left stick + 0, 0 → 0, 0 +
+
+ + + + + + +
+
+
+
+ Right stick + 0, 0 → 0, 0 +
+
+ + + + + + +
+
+
+
+ Triggers + Raw → draft output +
+
+ LT +
+ + + +
+ 0 → 0 +
+
+ RT +
+ + + +
+ 0 → 0 +
+
+ Raw + Draft output + Digital threshold +
+
+
+
@@ -201,6 +272,7 @@
+ diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index dab8099..dfdefb2 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -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) => ``) .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; diff --git a/src/switch_pico_bridge/web/profile_playtest.js b/src/switch_pico_bridge/web/profile_playtest.js new file mode 100644 index 0000000..e9d6357 --- /dev/null +++ b/src/switch_pico_bridge/web/profile_playtest.js @@ -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); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index cd2eeb9..bac34c5 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -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() { diff --git a/tests/profile_playtest_test.js b/tests/profile_playtest_test.js new file mode 100644 index 0000000..dd7bc49 --- /dev/null +++ b/tests/profile_playtest_test.js @@ -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); diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 018602f..8f1d751 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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(" None: assert len(payload) == 19 self.profile_transaction_id = struct.unpack_from(" 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: diff --git a/tests/test_prepare_bluepad32.py b/tests/test_prepare_bluepad32.py index fe17f76..f0f5b31 100644 --- a/tests/test_prepare_bluepad32.py +++ b/tests/test_prepare_bluepad32.py @@ -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: diff --git a/tests/test_profile_playtest_js.py b/tests/test_profile_playtest_js.py new file mode 100644 index 0000000..42c0d00 --- /dev/null +++ b/tests/test_profile_playtest_js.py @@ -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, + ) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index 0ff568a..a5a95d2 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -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 diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 2ea86f9..b66217f 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -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* output, size_t offset, (*output)[offset + 1] = static_cast(value >> 8); } +uint16_t read_u16(const std::vector& input, size_t offset) { + return static_cast(input[offset]) | + static_cast(input[offset + 1] << 8); +} + void write_u32(std::vector* output, size_t offset, uint32_t value) { (*output)[offset] = static_cast(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(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(read_u16( + control_payload, kResponseHeaderSize + 26)) == + -1234 && + read_u16(control_payload, kResponseHeaderSize + 36) == + 65000 && + static_cast(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; diff --git a/tools/prepare_bluepad32.py b/tools/prepare_bluepad32.py index 67a634e..c9e4ef2 100755 --- a/tools/prepare_bluepad32.py +++ b/tools/prepare_bluepad32.py @@ -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"