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

@ -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;