Add live profile playtest and input fixes
This commit is contained in:
parent
784276d9e5
commit
890f47ab80
20 changed files with 1083 additions and 20 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
65
tests/profile_playtest_test.js
Normal file
65
tests/profile_playtest_test.js
Normal 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);
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
16
tests/test_profile_playtest_js.py
Normal file
16
tests/test_profile_playtest_js.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue