feat: add model-aware Controller Studio artwork and topology
This commit is contained in:
parent
8dd11c17a6
commit
8451b1eec9
34 changed files with 1898 additions and 4186 deletions
|
|
@ -1344,6 +1344,12 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
solo.state.right_stick_x == 0 && solo.state.right_stick_y == 0 &&
|
||||
(right_first ? solo.state.button_south : solo.state.button_west),
|
||||
"solo JoyCon must rotate face controls, stick click and rail shoulders");
|
||||
Bluepad32PlaytestSnapshot playtest{};
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout ==
|
||||
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
|
||||
: Bluepad32ControllerLayout::kJoyCon2LeftSolo),
|
||||
"playtest must identify the live rotated solo half");
|
||||
require(solo.state.left_stick_x ==
|
||||
(right_first ? INT16_MAX : scale_axis(100)) &&
|
||||
solo.state.left_stick_y ==
|
||||
|
|
@ -1363,6 +1369,11 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
merged.connection_generation != solo.connection_generation &&
|
||||
controller_identity_equal(merged.identity, identity_for_device(&left)),
|
||||
"either connection order must merge into first output with left profile owner");
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout ==
|
||||
Bluepad32ControllerLayout::kJoyCon2MergedPair &&
|
||||
playtest.state.motion_sample_count == 0,
|
||||
"playtest must detect a live companion before any merged motion report");
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
|
||||
capture.state == CaptureState::kDisconnected,
|
||||
|
|
@ -1445,6 +1456,11 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
!detached.state.dpad_up && !detached.state.button_east &&
|
||||
(right_first ? detached.state.button_south : detached.state.button_west),
|
||||
"either half detach must immediately publish only the rotated survivor and own profile");
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout ==
|
||||
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
|
||||
: Bluepad32ControllerLayout::kJoyCon2LeftSolo),
|
||||
"playtest must stop presenting a pair immediately after companion loss");
|
||||
require(survivor.last_rumble_duration_ms == 0 &&
|
||||
slot_snapshot(2).connection_generation == ordinary_before.connection_generation &&
|
||||
slot_snapshot(2).state.button_north,
|
||||
|
|
@ -2031,6 +2047,29 @@ void test_rejections() {
|
|||
playtest.battery == 201 &&
|
||||
(playtest.capabilities & 0x08u) != 0,
|
||||
"playtest snapshot did not expose input capabilities");
|
||||
struct LayoutCase {
|
||||
uni_controller_subtype_t subtype;
|
||||
Bluepad32ControllerLayout layout;
|
||||
};
|
||||
const LayoutCase layouts[] = {
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiRemote},
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiRemote},
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiRemote},
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, Bluepad32ControllerLayout::kWiiNunchuk},
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, Bluepad32ControllerLayout::kWiiNunchuk},
|
||||
{CONTROLLER_SUBTYPE_WII_CLASSIC, Bluepad32ControllerLayout::kUnspecified},
|
||||
{CONTROLLER_SUBTYPE_WIIUPRO, Bluepad32ControllerLayout::kUnspecified},
|
||||
{CONTROLLER_SUBTYPE_WII_BALANCE_BOARD, Bluepad32ControllerLayout::kUnspecified},
|
||||
{CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET, Bluepad32ControllerLayout::kUnspecified},
|
||||
{CONTROLLER_SUBTYPE_NONE, Bluepad32ControllerLayout::kUnspecified},
|
||||
};
|
||||
for (const LayoutCase& expected : layouts) {
|
||||
slot_zero.controller_subtype = expected.subtype;
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout == expected.layout &&
|
||||
playtest.state.motion_sample_count == 3,
|
||||
"Wii layout must follow the live subtype, not motion or previous extension");
|
||||
}
|
||||
bluepad32_input_backend_report_sent(0);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count == 3,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,20 @@ enum {
|
|||
MISC_BUTTON_CAPTURE = 1 << 3,
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
CONTROLLER_SUBTYPE_NONE = 0,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_ACCEL,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK,
|
||||
CONTROLLER_SUBTYPE_UNUSED_00,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL,
|
||||
CONTROLLER_SUBTYPE_WII_CLASSIC,
|
||||
CONTROLLER_SUBTYPE_WIIUPRO,
|
||||
CONTROLLER_SUBTYPE_WII_BALANCE_BOARD,
|
||||
CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET,
|
||||
} uni_controller_subtype_t;
|
||||
|
||||
struct uni_gamepad_t {
|
||||
uint32_t dpad;
|
||||
uint32_t buttons;
|
||||
|
|
@ -148,6 +162,7 @@ inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer)
|
|||
struct uni_hid_device_s {
|
||||
uint16_t vendor_id;
|
||||
uint16_t product_id;
|
||||
uni_controller_subtype_t controller_subtype;
|
||||
uni_bt_conn_t conn;
|
||||
int idx;
|
||||
bool gamepad;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class FakeDevice:
|
|||
self.playtest_state_generation = 93
|
||||
self.playtest_button_mask = 0x9001
|
||||
self.playtest_extra_buttons = 0
|
||||
self.playtest_layout = 0
|
||||
self.playtest_sticks = (-1234, 2345, -30000, 30000)
|
||||
self.playtest_triggers = (123, 65000)
|
||||
self.playtest_motion = (1, -2, 3, -4, 5, -6)
|
||||
|
|
@ -234,6 +235,7 @@ class FakeDevice:
|
|||
payload[39] = self.playtest_battery
|
||||
payload[40] = self.playtest_capabilities
|
||||
payload[54] = self.playtest_extra_buttons
|
||||
payload[55] = self.playtest_layout
|
||||
if self.playtest_motion is not None:
|
||||
struct.pack_into("<hhhhhh", payload, 42, *self.playtest_motion)
|
||||
return bytes(payload), flags
|
||||
|
|
@ -2103,25 +2105,45 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
|
|||
config_manager.parse_profile_playtest(envelope)
|
||||
|
||||
|
||||
def test_playtest_extra_inputs_and_legacy_firmware_are_distinct() -> None:
|
||||
def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None:
|
||||
device = FakeDevice()
|
||||
device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067)
|
||||
device.playtest_extra_buttons = 0x55
|
||||
device.playtest_layout = 3
|
||||
payload, flags = device._profile_playtest_payload()
|
||||
|
||||
def parse(data: bytes, schema: int) -> config_manager.ProfilePlaytest:
|
||||
return config_manager.parse_profile_playtest(config_manager.parse_response(
|
||||
make_response(config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema),
|
||||
config_manager.OP_PROFILE_PLAYTEST,
|
||||
))
|
||||
current = parse(payload, 3)
|
||||
|
||||
current = parse(payload, 4)
|
||||
assert current.to_json_object()["layout"] == "joycon2-pair"
|
||||
assert current.to_json_object()["extra_buttons"] == ["c", "gr", "left_sr", "right_sr"]
|
||||
assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"]
|
||||
legacy = parse(payload[:54], 2)
|
||||
assert legacy == replace(current, extra_buttons=0)
|
||||
assert legacy.to_json_object()["extra_buttons"] == []
|
||||
assert parse(payload[:55], 3) == replace(current, layout=None)
|
||||
assert parse(payload[:54], 2) == replace(current, extra_buttons=0, layout=None)
|
||||
assert parse(payload[:55] + b"\x00", 4).layout is None
|
||||
for data, schema in (
|
||||
(payload[:54] + b"\x80", 3),
|
||||
(payload[:54] + b"\x80\x03", 4),
|
||||
(payload[:55] + b"\x06", 4),
|
||||
(payload[:55] + b"\xff", 4),
|
||||
(payload, 2),
|
||||
(payload, 3),
|
||||
(payload[:55], 4),
|
||||
(payload + b"\x00", 4),
|
||||
(payload, 5),
|
||||
):
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
parse(data, schema)
|
||||
|
||||
device.playtest_connected = False
|
||||
disconnected, flags = device._profile_playtest_payload()
|
||||
assert parse(disconnected, 4).layout is None
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
parse(payload[:54] + b"\x80", 3)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
parse(payload, 2)
|
||||
parse(disconnected[:55] + b"\x03", 4)
|
||||
|
||||
|
||||
def test_profile_reset_and_activate_wait_for_correlated_transactions(
|
||||
|
|
|
|||
|
|
@ -68,11 +68,35 @@ def test_editor_serves_assets_and_complete_schema(
|
|||
with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response:
|
||||
response.read()
|
||||
assert response.headers["Content-Type"].startswith("text/javascript")
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/assets/controller-switch-pro.svg", timeout=2
|
||||
) as response:
|
||||
response.read()
|
||||
assert response.headers["Content-Type"] == "image/svg+xml"
|
||||
for filename in (
|
||||
"switch-pro-controller-simple.svg",
|
||||
"switch-2-pro-controller-simple.svg",
|
||||
"switch-2-joycon-left.svg",
|
||||
"switch-2-joycon-single.svg",
|
||||
"switch-2-joycons-connected.svg",
|
||||
"ps5-dualsense-simple.svg",
|
||||
"xbox-controller-simple.svg",
|
||||
"wii-remote-simple.svg",
|
||||
"wii-remote-nunchuk-simple.svg",
|
||||
):
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/assets/{filename}", timeout=2
|
||||
) as response:
|
||||
assert response.headers["Content-Type"] == "image/svg+xml"
|
||||
assert b"<svg" in response.read()
|
||||
for path in (
|
||||
"/assets/controller-switch-pro.svg",
|
||||
"/assets/controller-dualsense.svg",
|
||||
"/assets/controller-xbox.svg",
|
||||
"/assets/GAMEPAD_ASSET_LICENSE.txt",
|
||||
"/assets/../profile_editor.html",
|
||||
"/assets/%2e%2e/profile_editor.html",
|
||||
"/assets/%2e%2e%2fprofile_editor.html",
|
||||
"/assets/switch-2-joycon-left.svg/../profile_editor.html",
|
||||
):
|
||||
with pytest.raises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(f"{base_url}{path}", timeout=2)
|
||||
assert error.value.code == 404
|
||||
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
|
||||
|
|
@ -91,9 +115,13 @@ def test_editor_serves_assets_and_complete_schema(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("product_id", [0x2069, 0x2067, 0x2066])
|
||||
@pytest.mark.parametrize(("product_id", "extras"), [
|
||||
(0x2069, {"c", "gl", "gr"}),
|
||||
(0x2067, {"left_sl", "left_sr"}),
|
||||
(0x2066, {"c", "right_sl", "right_sr"}),
|
||||
])
|
||||
def test_switch2_input_choices_are_never_output_targets(
|
||||
monkeypatch: pytest.MonkeyPatch, product_id: int,
|
||||
monkeypatch: pytest.MonkeyPatch, product_id: int, extras: set[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id)
|
||||
|
|
@ -106,7 +134,8 @@ def test_switch2_input_choices_are_never_output_targets(
|
|||
assert status == 200
|
||||
owner = listing["identities"][1]
|
||||
assert owner["controller"]["style"] == "switch"
|
||||
assert set(config_manager.EXTRA_BUTTONS) <= set(owner["modifier_controls"])
|
||||
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
||||
assert set(owner["modifier_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
||||
assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"])
|
||||
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
|
||||
|
||||
|
|
@ -194,23 +223,85 @@ def test_editor_identifies_connected_controller_artwork(
|
|||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
|
||||
assert status == 200
|
||||
assert [identity["controller"] for identity in listing["identities"]] == [
|
||||
{"model": "Generic controller", "style": "generic"},
|
||||
{"model": "Nintendo Switch Pro Controller", "style": "switch"},
|
||||
{"model": "Sony DualSense", "style": "playstation"},
|
||||
{"model": "Xbox controller", "style": "xbox"},
|
||||
]
|
||||
assert [identity["label"] for identity in listing["identities"]] == [
|
||||
"Default profile",
|
||||
"Switch Pro · 05:06",
|
||||
"DualSense · 15:16",
|
||||
"Xbox · 50:60",
|
||||
assert [
|
||||
(identity["controller"]["style"], identity["controller"]["layout"])
|
||||
for identity in listing["identities"]
|
||||
] == [
|
||||
("generic", "generic"), ("switch", "switch-pro"),
|
||||
("playstation", "dualsense"), ("xbox", "xbox"),
|
||||
]
|
||||
assert [identity["key"] for identity in listing["identities"]] == [
|
||||
identity.to_bytes().hex() for identity in device.profile_identities
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner_index", [0, 1])
|
||||
def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
|
||||
monkeypatch: pytest.MonkeyPatch, owner_index: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
left = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067)
|
||||
device.stable_identity = left
|
||||
device.profile_identities = [device.global_identity, left]
|
||||
device.active_profiles[left.to_bytes()] = 0
|
||||
device.profiles[(left.to_bytes(), 0)] = config_manager.ControllerProfile.default().to_bytes()
|
||||
device.playtest_motion = None
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
for code, expected in ((1, "joycon2-left"), (3, "joycon2-pair"), (1, "joycon2-left")):
|
||||
device.playtest_layout = code
|
||||
status, sample = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest")
|
||||
assert status == 200
|
||||
assert sample["controller"]["layout"] == expected
|
||||
assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex()
|
||||
assert sample["identity_key"] == left.to_bytes().hex()
|
||||
expected_extras = (
|
||||
{"c", "left_sl", "left_sr", "right_sl", "right_sr"}
|
||||
if code == 3 else {"left_sl", "left_sr"}
|
||||
)
|
||||
assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert listing["identities"][1]["controller"]["layout"] == "joycon2-left"
|
||||
device.playtest_connected = False
|
||||
status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest")
|
||||
assert status == 200
|
||||
assert offline["connected"] is False
|
||||
assert offline["layout"] is None
|
||||
assert offline["controller"] == listing["identities"][owner_index]["controller"]
|
||||
|
||||
|
||||
def test_live_metadata_cannot_turn_an_unrelated_identity_into_a_pair(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.playtest_layout = 3
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
|
||||
assert status == 200
|
||||
assert sample["controller"]["layout"] == "xbox"
|
||||
|
||||
|
||||
def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x0330)
|
||||
device.profile_identities = [device.global_identity, device.stable_identity]
|
||||
device.active_profiles[device.stable_identity.to_bytes()] = 0
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert listing["identities"][1]["controller"]["layout"] == "generic"
|
||||
for code, expected in ((0, "generic"), (4, "wii-remote"), (5, "wii-nunchuk"), (0, "generic")):
|
||||
device.playtest_layout = code
|
||||
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
|
||||
assert status == 200
|
||||
assert sample["controller"]["layout"] == expected
|
||||
if code:
|
||||
assert set(sample["source_controls"]).isdisjoint(config_manager.EXTRA_BUTTONS)
|
||||
assert ("left_shoulder" in sample["source_controls"]) == (code == 5)
|
||||
|
||||
|
||||
def test_editor_reads_writes_and_activates_profiles_atomically(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -222,19 +313,12 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
|
|||
assert listing["identities"][1]["key"] == (
|
||||
device.stable_identity.to_bytes().hex()
|
||||
)
|
||||
assert listing["identities"][1]["controller"] == {
|
||||
"model": "Xbox controller",
|
||||
"style": "xbox",
|
||||
}
|
||||
assert listing["identities"][1]["controller"]["layout"] == "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["controller"]["layout"] == "xbox"
|
||||
assert playtest["buttons"] == [
|
||||
"south",
|
||||
"dpad_up",
|
||||
|
|
|
|||
|
|
@ -518,6 +518,8 @@ void test_profile_vendor_requests() {
|
|||
current_playtest[2].identity = expected_identity;
|
||||
current_playtest[2].physical_button_mask = 0x8001;
|
||||
current_playtest[2].state.extra_buttons = 0x7f;
|
||||
current_playtest[2].controller_layout =
|
||||
Bluepad32ControllerLayout::kJoyCon2MergedPair;
|
||||
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;
|
||||
|
|
@ -558,14 +560,24 @@ void test_profile_vendor_requests() {
|
|||
control_payload, kResponseHeaderSize + 52)) == -6 &&
|
||||
control_payload[kResponseHeaderSize + 39] == 201 &&
|
||||
control_payload[kResponseHeaderSize + 40] == 0x0f &&
|
||||
control_payload[kResponseHeaderSize + 54] == 0x7f,
|
||||
control_payload[kResponseHeaderSize + 54] == 0x7f &&
|
||||
control_payload[kResponseHeaderSize + 55] == 3,
|
||||
"profile playtest response lost live controller state");
|
||||
current_playtest[2].controller_layout =
|
||||
static_cast<Bluepad32ControllerLayout>(6);
|
||||
uint8_t invalid_playtest[kMaximumResponseSize]{};
|
||||
require(encode_profile_playtest(
|
||||
2, current_playtest[2], invalid_playtest, sizeof(invalid_playtest)) == 0,
|
||||
"unknown playtest layout metadata must be rejected");
|
||||
current_playtest[2].controller_layout =
|
||||
Bluepad32ControllerLayout::kJoyCon2MergedPair;
|
||||
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 &&
|
||||
control_payload[kResponseHeaderSize + 54] == 0,
|
||||
control_payload[kResponseHeaderSize + 54] == 0 &&
|
||||
control_payload[kResponseHeaderSize + 55] == 0,
|
||||
"disconnected profile playtest was not encoded");
|
||||
current_profile_metadata = {};
|
||||
current_profile_metadata.metadata.state = ProfileServiceState::kReady;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue