From ca82734bb8f90f741086b3e9949712c5fc24b8f7 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:25:38 -0700 Subject: [PATCH] sio: add the NeGcon (0x23) controller type SIO_PAD_NEGCON = 3 presents a Namco NeGcon (NPC-101): poll ID 0x23 with buttons, twist (left X), then analog I, II and L (Mednafen negcon.cpp). sio_set_pad_negcon() supplies I/II/L per frame like the sticks. The device has no config mode: any command but 0x42 gets the ID byte with no ACK, and game bytes in a poll drive no motors. Multitap seats stay digital unless the analog-on-tap hack is armed; the bulk status block carries the NeGcon frame. Entering or leaving NeGcon is a device swap, not the DualShock analog button: a deferred request applies even while the game holds the 0x44 mode lock, and the swap clears the lock, config latch and motor map. Requests raised mid-config still wait for config exit. 0x44 now selects the analog mode of the device in the port: a JogCon the game set to digital returns as a JogCon instead of becoming a DualShock. The mode lock and that device are guest state, so they join the snapshot as an appended section (older snapshots still load, unlocked). pad_status reports negcon and mode_locked per slot. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 13 + runtime/include/sio.h | 22 +- runtime/src/debug_server.c | 14 +- runtime/src/sio.c | 183 +++++++++-- runtime/tests/test_sio_dualshock_rumble.c | 13 +- runtime/tests/test_sio_jogcon_protocol.c | 10 +- runtime/tests/test_sio_negcon_protocol.c | 364 ++++++++++++++++++++++ 7 files changed, 581 insertions(+), 38 deletions(-) create mode 100644 runtime/tests/test_sio_negcon_protocol.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 4e0bcb85..81b4d10e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -407,6 +407,19 @@ if(BUILD_TESTING) src/sio.c) target_include_directories(sio_jogcon_protocol_test PRIVATE include) add_test(NAME sio_jogcon_protocol_test COMMAND sio_jogcon_protocol_test) + add_executable(sio_negcon_protocol_test + tests/test_sio_negcon_protocol.c + src/sio.c) + target_include_directories(sio_negcon_protocol_test PRIVATE include) + add_test(NAME sio_negcon_protocol_test COMMAND sio_negcon_protocol_test) + add_executable(sio_negcon_protocol_multitap_test + tests/test_sio_negcon_protocol.c + src/sio.c) + target_include_directories(sio_negcon_protocol_multitap_test PRIVATE include) + target_compile_definitions(sio_negcon_protocol_multitap_test PRIVATE + PSX_MAX_PLAYERS=5) + add_test(NAME sio_negcon_protocol_multitap_test + COMMAND sio_negcon_protocol_multitap_test) add_executable(controller_type_test tests/test_controller_type.cpp) target_include_directories(controller_type_test PRIVATE include) diff --git a/runtime/include/sio.h b/runtime/include/sio.h index ad3f242b..dab3ee58 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -55,7 +55,12 @@ extern "C" { #define SIO_BASE 0x1F801040 /* Emulated controller identity used by the game's SIO poll. */ -enum { SIO_PAD_DIGITAL = 0, SIO_PAD_DUALSHOCK = 1, SIO_PAD_JOGCON = 2 }; +enum { + SIO_PAD_DIGITAL = 0, + SIO_PAD_DUALSHOCK = 1, + SIO_PAD_JOGCON = 2, + SIO_PAD_NEGCON = 3 /* NPC-101: ID 0x23, twist + analog I/II/L, no config */ +}; /* Phase 1.0c-v2: cycle-paced SIO model. Default 1 enables the dispatch- * loop quantum tick (gated by g_sio_timing_active). Set to 0 to revert @@ -145,7 +150,8 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons); void sio_set_pad_analog(int slot, int enabled, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); /* Set one of SIO_PAD_* explicitly. JogCon consumes left-X as a signed - * steering position, centered at 0x80; the remaining axes are ignored. */ + * steering position, centered at 0x80; NeGcon consumes left-X as its 0..255 + * twist (0x80 centred). Both ignore the remaining axes. */ void sio_set_pad_type(int slot, int type, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); @@ -156,8 +162,18 @@ void sio_set_pad_type(int slot, int type, * flip can never split a poll or a config handshake. Do NOT call * sio_set_pad_analog every frame for the type — that is for boot/hotplug only. */ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry); -/* Request SIO_PAD_* at the next idle, non-config bus boundary. */ +/* Request SIO_PAD_* at the next idle, non-config bus boundary. A game's 0x44 + * mode lock holds DualShock/digital/JogCon flips; entering or leaving NeGcon is + * a device swap that applies anyway and clears the lock, config latch and + * motor map (a different controller in the port). */ void sio_request_pad_type(int slot, int type); +/* NeGcon analog buttons I, II and L for the next poll (0 released .. 0xFF + * fully pressed). Host input like the sticks; ignored unless the slot reports + * SIO_PAD_NEGCON. */ +void sio_set_pad_negcon(int slot, uint8_t i, uint8_t ii, uint8_t l); +void sio_get_pad_negcon(int slot, uint8_t out[3]); +/* 1 while the game has locked the pad's analog mode (0x44 lock byte 0x03). */ +int sio_get_pad_mode_locked(int slot); /* Connect / disconnect a logical pad (0 .. PSX_MAX_PLAYERS-1). By default no * pads are connected during initial BIOS boot. */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index ebc6b403..7d7a47ee 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7880,26 +7880,34 @@ extern uint16_t sio_get_pad_buttons_slot(int slot); extern int sio_get_pad_connected(int slot); extern int sio_get_pad_analog(int slot); extern void sio_get_pad_sticks(int slot, uint8_t out[4]); +extern void sio_get_pad_negcon(int slot, uint8_t out[3]); +extern int sio_get_pad_mode_locked(int slot); static void handle_pad_status(int id, const char *json) { (void)json; uint16_t pad0 = sio_get_pad_buttons_slot(0); uint16_t pad1 = sio_get_pad_buttons_slot(1); - uint8_t sticks0[4], sticks1[4]; + uint8_t sticks0[4], sticks1[4], neg0[3], neg1[3]; sio_get_pad_sticks(0, sticks0); sio_get_pad_sticks(1, sticks1); + sio_get_pad_negcon(0, neg0); + sio_get_pad_negcon(1, neg1); send_fmt("{\"id\":%d,\"ok\":true,\"pad\":\"0x%04X\"," - "\"slot0\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]}," - "\"slot1\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]}," + "\"slot0\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]," + "\"negcon\":[%u,%u,%u],\"mode_locked\":%s}," + "\"slot1\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]," + "\"negcon\":[%u,%u,%u],\"mode_locked\":%s}," "\"override\":%d,\"override_frames\":%d,\"override_pad_type\":%d," "\"override_axes\":[%u,%u,%u,%u],\"override_axes_valid\":%s}\n", id, pad0, pad0, sio_get_pad_connected(0) ? "true" : "false", sio_get_pad_analog(0) ? "true" : "false", sio_get_pad_analog(0), sticks0[0], sticks0[1], sticks0[2], sticks0[3], + neg0[0], neg0[1], neg0[2], sio_get_pad_mode_locked(0) ? "true" : "false", pad1, sio_get_pad_connected(1) ? "true" : "false", sio_get_pad_analog(1) ? "true" : "false", sio_get_pad_analog(1), sticks1[0], sticks1[1], sticks1[2], sticks1[3], + neg1[0], neg1[1], neg1[2], sio_get_pad_mode_locked(1) ? "true" : "false", s_input_override, s_input_frames, s_pad_type_override, s_axis_st[0], s_axis_st[1], s_axis_st[2], s_axis_st[3], s_axis_override ? "true" : "false"); diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 51b8a290..67f6e3b9 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -48,11 +48,24 @@ static void sio_debug_poll_maybe(void) { static uint16_t pad_buttons[PSX_MAX_PLAYERS] = { PSX_PAD_INIT(0xFFFF) }; /* Per-logical-pad type + analog stick state. Values are SIO_PAD_*; sticks are - * 0..255, 0x80 centred. JogCon uses left-X as a signed wheel position. */ + * 0..255, 0x80 centred. JogCon uses left-X as a signed wheel position; NeGcon + * uses it as the 0..255 twist axis (0x80 centred). */ static PSX_BSS uint8_t pad_analog[PSX_MAX_PLAYERS]; static uint8_t pad_stick[PSX_MAX_PLAYERS][4] = { PSX_PAD_INIT({ 0x80, 0x80, 0x80, 0x80 }) }; /* lx,ly,rx,ry */ +/* NeGcon analog buttons I, II and L (0x00 released .. 0xFF fully pressed). + * Host input like the sticks: refreshed before each guest poll, not device + * state, so it is not part of the snapshot. */ +static PSX_BSS uint8_t pad_negcon_analog[PSX_MAX_PLAYERS][3]; +/* The analog-capable device behind a config-mode pad: SIO_PAD_DUALSHOCK or + * SIO_PAD_JOGCON. Command 0x44 switches that device between its analog mode + * and digital mode (0x41); remembering the device keeps a JogCon that the game + * put in digital mode a JogCon when 0x44 selects analog again, instead of + * turning it into a DualShock. Guest-visible, so it is snapshotted. */ +static uint8_t pad_analog_device[PSX_MAX_PLAYERS] = { + PSX_PAD_INIT(SIO_PAD_DUALSHOCK) +}; /* DualShock command 0x4D maps the six writable bytes in a 0x42 poll onto the * two motors: 0x00 = small/high-frequency, 0x01 = large/low-frequency, @@ -252,10 +265,25 @@ static void mtap_finish_42(void) { static uint8_t pad_protocol_id(int logical) { if (pad_in_config[logical]) return 0xF3; if (pad_analog[logical] == SIO_PAD_JOGCON) return 0xE3; + if (pad_analog[logical] == SIO_PAD_NEGCON) return 0x23; if (pad_analog[logical] == SIO_PAD_DUALSHOCK) return 0x73; return 0x41; } +/* NeGcon (NPC-101) poll payload: buttons, twist, then analog I, II and L + * (Mednafen psx/input/negcon.cpp). The device has no config mode and no + * motors; every byte is fixed by the current host sample. */ +static void pad_fill_negcon_poll(int logical, uint8_t out[8]) { + out[0] = 0x23; + out[1] = 0x5A; + out[2] = (uint8_t)(pad_buttons[logical] & 0xFF); + out[3] = (uint8_t)(pad_buttons[logical] >> 8); + out[4] = pad_stick[logical][0]; + out[5] = pad_negcon_analog[logical][0]; + out[6] = pad_negcon_analog[logical][1]; + out[7] = pad_negcon_analog[logical][2]; +} + /* Poll payload for the JogCon. Steering is a signed absolute position; the * direction bits report movement since the preceding poll. Byte 7 is reserved. */ static void pad_fill_jogcon_poll(int logical, uint8_t id, uint8_t out[8]) { @@ -275,7 +303,8 @@ static void pad_fill_jogcon_poll(int logical, uint8_t id, uint8_t out[8]) { /* Fill 8-byte per-pad status block used in multitap bulk 0x42 responses. * Disconnected → all 0xFF. Digital → 0x41, DualShock → 0x73 + axes, - * JogCon → 0xE3 + signed steering, config mode → corresponding config ID. */ + * JogCon → 0xE3 + signed steering, NeGcon → 0x23 + twist/I/II/L, + * config mode → corresponding config ID. */ static void pad_fill_status8(int logical, uint8_t out[8]) { if (logical < 0 || logical >= PSX_MAX_PLAYERS || !(pad_connected & (1u << logical))) { @@ -292,6 +321,8 @@ static void pad_fill_status8(int logical, uint8_t out[8]) { uint8_t poll[8]; pad_fill_jogcon_poll(logical, id, poll); memcpy(out, poll, sizeof(poll)); + } else if (pad_analog[logical] == SIO_PAD_NEGCON) { + pad_fill_negcon_poll(logical, out); } else if (pad_analog[logical] || pad_in_config[logical]) { out[4] = pad_stick[logical][2]; /* right X */ out[5] = pad_stick[logical][3]; /* right Y */ @@ -823,7 +854,9 @@ void sio_init(void) { memset(pad_rumble_large, 0, sizeof(pad_rumble_large)); memset(pad_jogcon_last_steering, 0, sizeof(pad_jogcon_last_steering)); memset(pad_jogcon_motor_command, 0, sizeof(pad_jogcon_motor_command)); + memset(pad_negcon_analog, 0, sizeof(pad_negcon_analog)); for (int i = 0; i < PSX_MAX_PLAYERS; i++) { + pad_analog_device[i] = SIO_PAD_DUALSHOCK; pad_buttons[i] = 0xFFFF; pad_analog[i] = 0; pad_stick[i][0] = pad_stick[i][1] = pad_stick[i][2] = pad_stick[i][3] = 0x80; @@ -1003,6 +1036,7 @@ void sio_netplay_canonicalize_session_pads(int slot_count) } pad_in_config[i] = 0; analog_mode_locked[i] = 0; + pad_analog_device[i] = SIO_PAD_DUALSHOCK; pad_type_req[i] = -1; memset(pad_rumble_map[i], 0xFF, sizeof(pad_rumble_map[i])); pad_rumble_small[i] = 0; @@ -1061,6 +1095,35 @@ void sio_set_pad_state_slot(int slot, uint16_t buttons) { if (slot >= 0 && slot < PSX_MAX_PLAYERS) pad_buttons[slot] = buttons; } +/* Entering or leaving NeGcon swaps the controller on the port; it is not the + * DualShock analog button. The NeGcon is a pre-DualShock device with no config + * mode, mode lock or motors, so the swap clears the config latch, the 0x44 mode + * lock and the rumble map/motors exactly as unplugging one pad and plugging in + * another would. A game-locked analog mode therefore never pins a DualShock in + * place of a requested NeGcon (the lock belongs to the device that left), and a + * DualShock that returns powers up unlocked with an unassigned motor map. */ +static int pad_type_is_device_swap(uint8_t from, uint8_t to) { + return (from == SIO_PAD_NEGCON) != (to == SIO_PAD_NEGCON); +} + +static void pad_commit_type(int slot, uint8_t type) { + const uint8_t from = pad_analog[slot]; + if (from != type) { + pad_jogcon_last_steering[slot] = 0; + pad_jogcon_motor_command[slot] = 0; + } + if (pad_type_is_device_swap(from, type)) { + pad_in_config[slot] = 0; + analog_mode_locked[slot] = 0; + memset(pad_rumble_map[slot], 0xFF, sizeof(pad_rumble_map[slot])); + pad_rumble_small[slot] = 0; + pad_rumble_large[slot] = 0; + } + if (type == SIO_PAD_DUALSHOCK || type == SIO_PAD_JOGCON) + pad_analog_device[slot] = type; + pad_analog[slot] = type; +} + /* Direct set of pad type + sticks. Used at boot/hotplug (refresh_player_devices) * to establish the initial pinned type; safe there because the bus is idle and * no handshake is in flight. Per-frame input must NOT use this for the type — @@ -1073,15 +1136,11 @@ void sio_set_pad_type(int slot, int type, type = SIO_PAD_DIGITAL; lx = ly = rx = ry = 0x80; } - if (type < SIO_PAD_DIGITAL || type > SIO_PAD_JOGCON) + if (type < SIO_PAD_DIGITAL || type > SIO_PAD_NEGCON) type = SIO_PAD_DIGITAL; if (type == SIO_PAD_JOGCON) pad_supports_config[slot] = 1; - if (pad_analog[slot] != (uint8_t)type) { - pad_jogcon_last_steering[slot] = 0; - pad_jogcon_motor_command[slot] = 0; - } - pad_analog[slot] = (uint8_t)type; + pad_commit_type(slot, (uint8_t)type); pad_type_req[slot] = -1; /* explicit set supersedes any pending request */ pad_stick[slot][0] = lx; pad_stick[slot][1] = ly; pad_stick[slot][2] = rx; pad_stick[slot][3] = ry; @@ -1100,13 +1159,33 @@ void sio_set_pad_sticks(int slot, uint8_t lx, uint8_t ly, uint8_t rx, uint8_t ry pad_stick[slot][2] = rx; pad_stick[slot][3] = ry; } +void sio_set_pad_negcon(int slot, uint8_t i, uint8_t ii, uint8_t l) { + if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + pad_negcon_analog[slot][0] = i; + pad_negcon_analog[slot][1] = ii; + pad_negcon_analog[slot][2] = l; +} + +void sio_get_pad_negcon(int slot, uint8_t out[3]) { + if (!out) return; + if (slot < 0 || slot >= PSX_MAX_PLAYERS) { + out[0] = out[1] = out[2] = 0; + return; + } + memcpy(out, pad_negcon_analog[slot], 3); +} + +int sio_get_pad_mode_locked(int slot) { + return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? analog_mode_locked[slot] : 0; +} + /* Per-frame type request (the emulated controller type). The flip is * deferred and applied atomically at the next idle, non-config boundary, so it * can never split a poll or a config handshake. A no-op if already that type. */ void sio_request_pad_type(int slot, int type) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; if (sio_tap_force_digital(slot)) type = SIO_PAD_DIGITAL; - if (type < SIO_PAD_DIGITAL || type > SIO_PAD_JOGCON) + if (type < SIO_PAD_DIGITAL || type > SIO_PAD_NEGCON) type = SIO_PAD_DIGITAL; /* A JogCon is a config-capable controller even when its type is selected * through this deferred per-frame path (debug injection / hot selection). @@ -1217,9 +1296,13 @@ static void pad_process_byte(uint8_t tx_byte) { for (int s = 0; s < PSX_MAX_PLAYERS; s++) { /* A game-LOCKED analog mode (0x44 ..03) ignores the physical analog * button — and our hybrid auto-flip IS that button — so a locked slot - * drops the pending host request instead of applying it. */ - if (pad_type_req[s] >= 0 && !pad_in_config[s] && !analog_mode_locked[s]) { - pad_analog[s] = (uint8_t)pad_type_req[s]; + * holds the pending host request instead of applying it. A device + * swap (into or out of NeGcon) is not that button: it replaces the + * locked device and applies regardless (see pad_commit_type). */ + if (pad_type_req[s] >= 0 && !pad_in_config[s] && + (!analog_mode_locked[s] || + pad_type_is_device_swap(pad_analog[s], (uint8_t)pad_type_req[s]))) { + pad_commit_type(s, (uint8_t)pad_type_req[s]); pad_type_req[s] = -1; } } @@ -1308,7 +1391,23 @@ static void pad_process_byte(uint8_t tx_byte) { * digital-only and just polls. Gate all config branches on this so a * digital-mode pad behaves like real hardware (see pad_supports_config). */ const int ds = pad_supports_config[lp]; - if (tx_byte == 0x42) { + if (pad_analog[lp] == SIO_PAD_NEGCON) { + /* NeGcon answers only the 0x42 poll. Its ID byte goes out with any + * command, but for anything else it does not ACK and the + * transaction ends (Mednafen negcon.cpp phase 1). */ + pad_fill_negcon_poll(lp, pad_response); + sio_rx_data = pad_response[0]; + if (tx_byte == 0x42) { + pad_response_len = 8; + pad_state = PAD_SEND_RESPONSE; + sio_stat |= SIO_STAT_ACK; + } else { + pad_state = PAD_IDLE; + pad_response_len = 0; + pad_response_idx = 0; + pad_current_cmd = 0; + } + } else if (tx_byte == 0x42) { /* Read poll. Analog (or in-config) uses the 8-byte format with the * four stick axes; a plain digital pad uses the 4-byte format. */ const uint16_t btn = pad_buttons[lp]; @@ -1510,7 +1609,7 @@ static void pad_process_byte(uint8_t tx_byte) { } /* The six data bytes after 0x42's leading 0x00 occupy response * indexes 2..7. Route each through the map negotiated by 0x4D. */ - if (pad_current_cmd == 0x42 && + if (pad_current_cmd == 0x42 && pad_analog[rs] != SIO_PAD_NEGCON && pad_response_idx >= 2 && pad_response_idx < 8) { const unsigned map_index = (unsigned)pad_response_idx - 2u; const uint8_t motor = pad_rumble_map[rs][map_index]; @@ -1546,12 +1645,11 @@ static void pad_process_byte(uint8_t tx_byte) { * request so it can't immediately undo the game's choice. */ if (!g_pad_legacy_cfg && pad_current_cmd == 0x44 && pad_response_idx == 2 && pad_active_logical >= 0 && pad_active_logical < PSX_MAX_PLAYERS) { - if (pad_analog[pad_active_logical] == SIO_PAD_JOGCON) - pad_analog[pad_active_logical] = - (tx_byte == 0x01) ? SIO_PAD_JOGCON : SIO_PAD_DIGITAL; - else - pad_analog[pad_active_logical] = - (tx_byte == 0x01) ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL; + /* Analog selects the analog mode of the device in the port (a JogCon + * stays a JogCon after a digital round trip), never a new device. */ + pad_analog[pad_active_logical] = + (tx_byte == 0x01) ? pad_analog_device[pad_active_logical] + : (uint8_t)SIO_PAD_DIGITAL; pad_type_req[pad_active_logical] = -1; } /* 0x44 lock byte (data position 4, the byte after the mode byte): 0x03 => @@ -2905,6 +3003,23 @@ static int sio_snap_emit_jogcon(PstW *w) { sizeof(pad_jogcon_motor_command)); } +/* 0x44 mode lock and the analog-capable device behind each port. Both are set + * by the guest (0x44) or by a device swap, so rollback/rewind must restore + * them; appended after JogCon so older snapshots stay loadable. */ +static int sio_snap_emit_pad_mode(PstW *w) { + return pst_w_bytes(w, analog_mode_locked, sizeof(analog_mode_locked)) && + pst_w_bytes(w, pad_analog_device, sizeof(pad_analog_device)); +} + +static void sio_pad_mode_legacy_defaults(void) { + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + analog_mode_locked[s] = 0; + pad_analog_device[s] = pad_analog[s] == SIO_PAD_JOGCON + ? (uint8_t)SIO_PAD_JOGCON + : (uint8_t)SIO_PAD_DUALSHOCK; + } +} + static int sio_snap_emit_fsm(PstW *w) { return sio_snap_emit_fsm_pace(w) && sio_snap_emit_fsm_meta(w); } @@ -2912,12 +3027,12 @@ static int sio_snap_emit_fsm(PstW *w) { static int sio_snap_emit(PstW *w) { return sio_snap_emit_regs(w) && sio_snap_emit_pads(w) && sio_snap_emit_mc(w) && sio_snap_emit_fsm(w) && sio_snap_emit_rumble(w) && - sio_snap_emit_jogcon(w); + sio_snap_emit_jogcon(w) && sio_snap_emit_pad_mode(w); } /* Cumulative section end offsets in the snapshot wire: * out[0]=regs, out[1]=pads, out[2]=memcard, out[3]=fsm_pace (netplay), - * out[4]=full including fsm metadata, rumble, and JogCon state. */ + * out[4]=full including fsm metadata, rumble, JogCon and pad-mode state. */ void sio_snapshot_section_ends(uint32_t out[5]) { PstW w; pst_w_init(&w, NULL, 0); @@ -2932,6 +3047,7 @@ void sio_snapshot_section_ends(uint32_t out[5]) { (void)sio_snap_emit_fsm_meta(&w); (void)sio_snap_emit_rumble(&w); (void)sio_snap_emit_jogcon(&w); + (void)sio_snap_emit_pad_mode(&w); out[4] = (uint32_t)w.written; } @@ -3027,6 +3143,7 @@ static int sio_snap_parse(PstR *r) { pad_rumble_map[s][0] = 0x00; pad_rumble_map[s][1] = 0x01; } + sio_pad_mode_legacy_defaults(); return 1; } if (!pst_r_bytes(r, pad_rumble_map, sizeof(pad_rumble_map)) || @@ -3038,6 +3155,7 @@ static int sio_snap_parse(PstR *r) { if (r->p == r->end) { memset(pad_jogcon_last_steering, 0, sizeof(pad_jogcon_last_steering)); memset(pad_jogcon_motor_command, 0, sizeof(pad_jogcon_motor_command)); + sio_pad_mode_legacy_defaults(); return 1; } if (!pst_r_bytes(r, pad_jogcon_last_steering, @@ -3045,6 +3163,19 @@ static int sio_snap_parse(PstR *r) { !pst_r_bytes(r, pad_jogcon_motor_command, sizeof(pad_jogcon_motor_command))) return 0; + /* Pre-pad-mode snapshots: unlocked, device inferred from the type. */ + if (r->p == r->end) { + sio_pad_mode_legacy_defaults(); + return 1; + } + if (!pst_r_bytes(r, analog_mode_locked, sizeof(analog_mode_locked)) || + !pst_r_bytes(r, pad_analog_device, sizeof(pad_analog_device))) + return 0; + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { + analog_mode_locked[s] = analog_mode_locked[s] ? 1 : 0; + if (pad_analog_device[s] != SIO_PAD_JOGCON) + pad_analog_device[s] = SIO_PAD_DUALSHOCK; + } if (r->p != r->end) return 0; return 1; } @@ -3073,9 +3204,13 @@ int sio_snapshot_validate(const uint8_t *p, uint32_t len) { sizeof(pad_rumble_large)); const uint32_t jogcon_bytes = (uint32_t)(sizeof(pad_jogcon_last_steering) + sizeof(pad_jogcon_motor_command)); + const uint32_t mode_bytes = (uint32_t)(sizeof(analog_mode_locked) + + sizeof(pad_analog_device)); return p && (len == current || - (len < current && len + jogcon_bytes == current) || - (len < current && len + jogcon_bytes + rumble_bytes == current)); + (len < current && len + mode_bytes == current) || + (len < current && len + mode_bytes + jogcon_bytes == current) || + (len < current && + len + mode_bytes + jogcon_bytes + rumble_bytes == current)); } int sio_snapshot_read(const uint8_t *p, uint32_t len) { diff --git a/runtime/tests/test_sio_dualshock_rumble.c b/runtime/tests/test_sio_dualshock_rumble.c index ae18d1b4..65cf5cf1 100644 --- a/runtime/tests/test_sio_dualshock_rumble.c +++ b/runtime/tests/test_sio_dualshock_rumble.c @@ -170,16 +170,19 @@ int main(void) { EXPECT("snapshot.current.small", 0x01, small); EXPECT("snapshot.current.large", 0x80, large); - /* JogCon rollback fields add four bytes for a two-seat build. Removing - * those restores the preceding rumble-era format; removing all twenty - * trailing bytes models a pre-rumble state. */ - EXPECT("snapshot.pre-jogcon.read", 1, + /* The pad-mode section (0x44 lock + device) and the JogCon rollback + * fields each add four bytes for a two-seat build. Removing them restores + * the preceding formats; removing all twenty-four trailing bytes models a + * pre-rumble state. */ + EXPECT("snapshot.pre-pad-mode.read", 1, sio_snapshot_read(snapshot, snapshot_len - 4)); + EXPECT("snapshot.pre-jogcon.read", 1, + sio_snapshot_read(snapshot, snapshot_len - 8)); EXPECT("snapshot.pre-jogcon.small", 0x01, (sio_get_pad_rumble(0, &small, &large), small)); EXPECT("snapshot.pre-jogcon.large", 0x80, large); EXPECT("snapshot.legacy.read", 1, - sio_snapshot_read(snapshot, snapshot_len - 20)); + sio_snapshot_read(snapshot, snapshot_len - 24)); sio_get_pad_rumble(0, &small, &large); EXPECT("snapshot.legacy.small", 0x00, small); EXPECT("snapshot.legacy.large", 0x00, large); diff --git a/runtime/tests/test_sio_jogcon_protocol.c b/runtime/tests/test_sio_jogcon_protocol.c index 0e8fb91e..1ad71da0 100644 --- a/runtime/tests/test_sio_jogcon_protocol.c +++ b/runtime/tests/test_sio_jogcon_protocol.c @@ -221,10 +221,14 @@ int main(void) { (void)xchg(0, 0x00); const uint32_t snapshot_len = sio_snapshot_bytes(); + /* JogCon steering[0] sits before steering[1..], the motor bytes and the + * trailing pad-mode section (lock + device per pad). */ + const uint32_t steering_at = snapshot_len - 2u * PSX_MAX_PLAYERS - + 2u * PSX_MAX_PLAYERS; uint8_t *snapshot = (uint8_t *)malloc(snapshot_len); if (!snapshot) return 1; sio_snapshot_write(snapshot); - EXPECT("snapshot.steering.field", 0x80, snapshot[snapshot_len - 4]); + EXPECT("snapshot.steering.field", 0x80, snapshot[steering_at]); sio_set_pad_sticks(0, 0xFF, 0x80, 0x80, 0x80); EXPECT("right.prefix", 0xFF, xchg(0, 0x01)); @@ -251,8 +255,8 @@ int main(void) { uint8_t *roundtrip = (uint8_t *)malloc(snapshot_len); if (roundtrip) { sio_snapshot_write(roundtrip); - EXPECT("rollback.steering.field", snapshot[snapshot_len - 4], - roundtrip[snapshot_len - 4]); + EXPECT("rollback.steering.field", snapshot[steering_at], + roundtrip[steering_at]); free(roundtrip); } } diff --git a/runtime/tests/test_sio_negcon_protocol.c b/runtime/tests/test_sio_negcon_protocol.c new file mode 100644 index 00000000..94d5304e --- /dev/null +++ b/runtime/tests/test_sio_negcon_protocol.c @@ -0,0 +1,364 @@ +/* NeGcon wire protocol regression: ID 0x23 poll layout (twist, I, II, L), + * no config mode, device-swap edges against the 0x44 mode lock, deferred type + * requests during config, 0x44 keeping the device, snapshot of the pad mode, + * and multitap force-digital (built a second time with PSX_MAX_PLAYERS=5). */ + +#include "sio.h" +#include +#include +#include +#include + +uint32_t sio_snapshot_bytes(void); +void sio_snapshot_write(uint8_t *p); +int sio_snapshot_read(const uint8_t *p, uint32_t len); + +uint32_t i_stat = 0; +uint32_t i_mask = 0; +uint32_t g_debug_current_func_addr = 0; +uint32_t g_debug_last_store_pc = 0; +int psx_get_in_exception(void) { return 0; } +uint8_t psx_read_byte(uint32_t addr) { (void)addr; return 0; } +uint32_t psx_read_word(uint32_t addr) { (void)addr; return 0; } + +/* sio.c gates card-transfer deferral on `psx_get_cycle_count() < deadline`, so + * a constant clock would defer forever. Advance monotonically. */ +uint64_t psx_get_cycle_count(void) { + static uint64_t t; + return t += 64; +} +uint32_t memory_get_sr(void) { return 0; } +void debug_server_poll(void) {} +void debug_server_log_sio_write(uint32_t a, uint32_t v, uint8_t w) { + (void)a; (void)v; (void)w; +} +void starvation_ring_record(uint8_t k, uint8_t tx, uint8_t rx, uint16_t c, + uint16_t st, uint8_t d, uint8_t ss, uint16_t ms, + uint8_t mc, uint16_t sec, uint8_t di, uint32_t f) { + (void)k; (void)tx; (void)rx; (void)c; (void)st; (void)d; (void)ss; + (void)ms; (void)mc; (void)sec; (void)di; (void)f; +} +void card_read_summary_record(uint8_t s, uint8_t c, uint16_t sec, uint8_t e, + uint8_t chk, uint8_t d0, uint8_t d1, uint32_t f) { + (void)s; (void)c; (void)sec; (void)e; (void)chk; (void)d0; (void)d1; (void)f; +} +void card_data_writes_arm(uint8_t v, uint16_t s, uint8_t i, uint8_t sl) { + (void)v; (void)s; (void)i; (void)sl; +} +void event_ring_record_aux(uint16_t k, uint8_t d, uint32_t a) { + (void)k; (void)d; (void)a; +} +void psx_irq_raise(uint32_t bit, uint32_t detail) { + (void)detail; i_stat |= 1u << bit; +} +int memcard_is_present(int card) { (void)card; return 0; } +int memcard_read_sector(int card, int sector, uint8_t *buf) { + (void)card; (void)sector; memset(buf, 0, 128); return -1; +} +int memcard_write_sector(int card, int sector, const uint8_t *buf) { + (void)card; (void)sector; (void)buf; return -1; +} +void memcard_flush(int card) { (void)card; } + +#define SIO_TX_DATA 0x1F801040u +#define SIO_RX_DATA 0x1F801040u +#define SIO_CTRL 0x1F80104Au +#define CTRL_TX_EN (1u << 0) +#define CTRL_SELECT (1u << 1) +#define CTRL_ACK (1u << 4) +#define CTRL_ACK_IRQ_EN (1u << 12) +#define CTRL_SLOT (1u << 13) + +static int failures; + +#define EXPECT(label, expected, actual) do { \ + unsigned e_ = (unsigned)(expected), a_ = (unsigned)(actual); \ + if (e_ != a_) { \ + fprintf(stderr, "FAIL %s: expected=0x%X actual=0x%X\n", \ + label, e_, a_); \ + failures++; \ + } \ +} while (0) + +static int last_ack; + +/* One byte on the pad bus; last_ack reports whether the device pulsed ACK + * (IRQ7) after it, i.e. whether it wants another byte. */ +static uint8_t xchg(int slot, uint8_t tx) { + const uint16_t ctrl = (uint16_t)(CTRL_TX_EN | CTRL_SELECT | + CTRL_ACK_IRQ_EN | (slot ? CTRL_SLOT : 0)); + i_stat = 0; + sio_write(SIO_CTRL, ctrl); + sio_write(SIO_TX_DATA, tx); + sio_tick(2000); + const uint8_t rx = (uint8_t)sio_read(SIO_RX_DATA); + last_ack = (i_stat & (1u << 7)) ? 1 : 0; + sio_write(SIO_CTRL, ctrl | CTRL_ACK); + return rx; +} + +/* Full 0x42 poll; returns the ID and fills the six bytes after 0x5A. */ +static uint8_t poll(int slot, uint8_t out[6]) { + uint8_t id; + (void)xchg(slot, 0x01); + id = xchg(slot, 0x42); + EXPECT("poll.status", 0x5A, xchg(slot, 0x00)); + for (int i = 0; i < 6; i++) out[i] = xchg(slot, 0x00); + return id; +} + +/* Short 0x42 poll for a 0x41 pad (two data bytes). */ +static uint8_t poll_id(int slot) { + uint8_t d[6]; + uint8_t id; + (void)xchg(slot, 0x01); + id = xchg(slot, 0x42); + if (id == 0x41) { + (void)xchg(slot, 0x00); (void)xchg(slot, 0x00); (void)xchg(slot, 0x00); + return id; + } + (void)xchg(slot, 0x00); + for (int i = 0; i < 6; i++) d[i] = xchg(slot, 0x00); + (void)d; + return id; +} + +/* 8-byte config command (0x43/0x44/0x4D...) with explicit data bytes. */ +static uint8_t config_cmd(int slot, uint8_t cmd, const uint8_t data[6], + uint8_t echo[6]) { + uint8_t id; + (void)xchg(slot, 0x01); + id = xchg(slot, cmd); + (void)xchg(slot, 0x00); + for (int i = 0; i < 6; i++) { + uint8_t r = xchg(slot, data[i]); + if (echo) echo[i] = r; + } + return id; +} + +static const uint8_t k_enter[6] = { 0x01, 0, 0, 0, 0, 0 }; +static const uint8_t k_exit[6] = { 0x00, 0, 0, 0, 0, 0 }; +static const uint8_t k_lock_analog[6] = { 0x01, 0x03, 0, 0, 0, 0 }; +static const uint8_t k_digital[6] = { 0x00, 0x02, 0, 0, 0, 0 }; +static const uint8_t k_analog[6] = { 0x01, 0x02, 0, 0, 0, 0 }; +static const uint8_t k_map[6] = { 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF }; + +static void negcon_wire(void) { + uint8_t d[6]; + sio_init(); + sio_connect_pad(0); + sio_set_pad_type(0, SIO_PAD_NEGCON, 0x80, 0x80, 0x80, 0x80); + sio_set_pad_state_slot(0, 0xBFEFu); /* Up + Cross held */ + sio_set_pad_sticks(0, 0x30, 0x11, 0x22, 0x33); /* twist = left X */ + sio_set_pad_negcon(0, 0xC0, 0x40, 0x07); + EXPECT("wire.prefix", 0xFF, xchg(0, 0x01)); + EXPECT("wire.prefix.ack", 1, last_ack); + EXPECT("wire.id", 0x23, xchg(0, 0x42)); + EXPECT("wire.id.ack", 1, last_ack); + EXPECT("wire.status", 0x5A, xchg(0, 0x00)); + EXPECT("wire.buttons.low", 0xEF, xchg(0, 0x00)); + EXPECT("wire.buttons.high", 0xBF, xchg(0, 0x00)); + EXPECT("wire.twist", 0x30, xchg(0, 0x00)); + EXPECT("wire.I", 0xC0, xchg(0, 0x00)); + EXPECT("wire.II", 0x40, xchg(0, 0x00)); + EXPECT("wire.L.ack", 1, last_ack); + EXPECT("wire.L", 0x07, xchg(0, 0x00)); + EXPECT("wire.end.noack", 0, last_ack); + + /* Not config-capable: any other command gets the ID byte with no ACK and + * the transaction ends; 0x43 never latches config mode. */ + EXPECT("cfg.prefix", 0xFF, xchg(0, 0x01)); + EXPECT("cfg.id", 0x23, xchg(0, 0x43)); + EXPECT("cfg.noack", 0, last_ack); + EXPECT("cfg.hiz", 0xFF, xchg(0, 0x00)); + EXPECT("cfg.after.id", 0x23, poll(0, d)); + EXPECT("cfg.after.I", 0xC0, d[3]); + + /* Pressure is host input: the next poll reports the new sample. */ + sio_set_pad_negcon(0, 0x00, 0xFF, 0x00); + EXPECT("resample.id", 0x23, poll(0, d)); + EXPECT("resample.I", 0x00, d[3]); + EXPECT("resample.II", 0xFF, d[4]); + { + uint8_t back[3]; + sio_get_pad_negcon(0, back); + EXPECT("getter.II", 0xFF, back[1]); + } +} + +/* A game-locked DualShock holds analog-button flips but not a NeGcon swap; + * the swap clears the lock and the motor map, and the DualShock that comes + * back powers up unlocked. */ +static void lock_and_swap(void) { + uint8_t d[6], echo[6]; + sio_init(); + sio_connect_pad(0); + sio_set_pad_type(0, SIO_PAD_DUALSHOCK, 0x80, 0x80, 0x80, 0x80); + sio_set_pad_config_capable(0, 1); + EXPECT("lock.enter.id", 0x73, config_cmd(0, 0x43, k_enter, NULL)); + EXPECT("lock.mode.id", 0xF3, config_cmd(0, 0x44, k_lock_analog, NULL)); + EXPECT("lock.map.id", 0xF3, config_cmd(0, 0x4D, k_map, NULL)); + EXPECT("lock.exit.id", 0xF3, config_cmd(0, 0x43, k_exit, NULL)); + EXPECT("lock.locked", 1, sio_get_pad_mode_locked(0)); + + sio_request_pad_type(0, SIO_PAD_DIGITAL); + EXPECT("lock.holds.digital", 0x73, poll(0, d)); + EXPECT("lock.holds.type", SIO_PAD_DUALSHOCK, sio_get_pad_analog(0)); + + sio_set_pad_negcon(0, 0x99, 0, 0); + sio_request_pad_type(0, SIO_PAD_NEGCON); + EXPECT("swap.in.id", 0x23, poll(0, d)); + EXPECT("swap.in.I", 0x99, d[3]); + EXPECT("swap.in.unlocked", 0, sio_get_pad_mode_locked(0)); + /* A NeGcon has no motors: game bytes in a poll drive nothing. */ + { + uint8_t small = 1, large = 1; + (void)xchg(0, 0x01); (void)xchg(0, 0x42); (void)xchg(0, 0x00); + (void)xchg(0, 0xFF); (void)xchg(0, 0xFF); + for (int i = 0; i < 4; i++) (void)xchg(0, 0x00); + sio_get_pad_rumble(0, &small, &large); + EXPECT("swap.in.small", 0, small); + EXPECT("swap.in.large", 0, large); + } + + sio_request_pad_type(0, SIO_PAD_DUALSHOCK); + EXPECT("swap.out.id", 0x73, poll(0, d)); + sio_request_pad_type(0, SIO_PAD_DIGITAL); + EXPECT("swap.out.unlocked.flip", 0x41, poll_id(0)); + sio_request_pad_type(0, SIO_PAD_DUALSHOCK); + EXPECT("swap.out.back", 0x73, poll(0, d)); + EXPECT("swap.out.enter", 0x73, config_cmd(0, 0x43, k_enter, NULL)); + EXPECT("swap.out.map.id", 0xF3, config_cmd(0, 0x4D, k_map, echo)); + for (int i = 0; i < 6; i++) + EXPECT("swap.out.map.unassigned", 0xFF, echo[i]); + (void)config_cmd(0, 0x43, k_exit, NULL); +} + +/* A type request raised during a config handshake waits for config exit. */ +static void deferred_during_config(void) { + uint8_t d[6]; + sio_init(); + sio_connect_pad(0); + sio_set_pad_type(0, SIO_PAD_DUALSHOCK, 0x80, 0x80, 0x80, 0x80); + EXPECT("defer.enter", 0x73, config_cmd(0, 0x43, k_enter, NULL)); + sio_request_pad_type(0, SIO_PAD_NEGCON); + EXPECT("defer.in.config", 0xF3, poll(0, d)); + EXPECT("defer.type.held", SIO_PAD_DUALSHOCK, sio_get_pad_analog(0)); + EXPECT("defer.exit", 0xF3, config_cmd(0, 0x43, k_exit, NULL)); + EXPECT("defer.applied", 0x23, poll(0, d)); +} + +/* 0x44 selects the analog mode of the device in the port: a JogCon that the + * game put in digital mode returns as a JogCon, not a DualShock. */ +static void set_mode_keeps_device(void) { + uint8_t d[6]; + sio_init(); + sio_connect_pad(0); + sio_set_pad_type(0, SIO_PAD_JOGCON, 0x80, 0x80, 0x80, 0x80); + EXPECT("0x44.enter", 0xE3, config_cmd(0, 0x43, k_enter, NULL)); + (void)config_cmd(0, 0x44, k_digital, NULL); + EXPECT("0x44.digital", SIO_PAD_DIGITAL, sio_get_pad_analog(0)); + (void)config_cmd(0, 0x44, k_analog, NULL); + EXPECT("0x44.jogcon", SIO_PAD_JOGCON, sio_get_pad_analog(0)); + (void)config_cmd(0, 0x43, k_exit, NULL); + EXPECT("0x44.poll", 0xE3, poll(0, d)); + + sio_set_pad_type(0, SIO_PAD_DUALSHOCK, 0x80, 0x80, 0x80, 0x80); + EXPECT("0x44.ds.enter", 0x73, config_cmd(0, 0x43, k_enter, NULL)); + (void)config_cmd(0, 0x44, k_digital, NULL); + (void)config_cmd(0, 0x44, k_analog, NULL); + EXPECT("0x44.dualshock", SIO_PAD_DUALSHOCK, sio_get_pad_analog(0)); + (void)config_cmd(0, 0x43, k_exit, NULL); +} + +/* Lock and device survive rollback; a snapshot from before the pad-mode + * section still loads, unlocked. */ +static void snapshot_pad_mode(void) { + uint8_t d[6]; + const uint32_t mode_bytes = 2u * PSX_MAX_PLAYERS; + sio_init(); + sio_connect_pad(0); + sio_set_pad_type(0, SIO_PAD_JOGCON, 0x80, 0x80, 0x80, 0x80); + (void)config_cmd(0, 0x43, k_enter, NULL); + (void)config_cmd(0, 0x44, k_lock_analog, NULL); + (void)config_cmd(0, 0x43, k_exit, NULL); + EXPECT("snap.locked", 1, sio_get_pad_mode_locked(0)); + + const uint32_t len = sio_snapshot_bytes(); + uint8_t *snap = (uint8_t *)malloc(len); + if (!snap) { failures++; return; } + sio_snapshot_write(snap); + + sio_request_pad_type(0, SIO_PAD_NEGCON); + EXPECT("snap.swap", 0x23, poll(0, d)); + EXPECT("snap.swap.unlocked", 0, sio_get_pad_mode_locked(0)); + + EXPECT("snap.restore", 1, sio_snapshot_read(snap, len)); + EXPECT("snap.restore.type", SIO_PAD_JOGCON, sio_get_pad_analog(0)); + EXPECT("snap.restore.locked", 1, sio_get_pad_mode_locked(0)); + sio_request_pad_type(0, SIO_PAD_DIGITAL); + EXPECT("snap.restore.lock.holds", 0xE3, poll(0, d)); + + EXPECT("snap.legacy.restore", 1, sio_snapshot_read(snap, len - mode_bytes)); + EXPECT("snap.legacy.unlocked", 0, sio_get_pad_mode_locked(0)); + EXPECT("snap.bad.len", 0, sio_snapshot_read(snap, len - 1)); + free(snap); +} + +#if PSX_MAX_PLAYERS >= 5 +/* Tap seats are digital unless the DualShock-on-tap hack is armed; with it, + * the bulk status block carries the NeGcon payload. */ +static void multitap(void) { + uint8_t r[34]; + sio_init(); + sio_set_multitap(1); + sio_set_multitap_port(0); + sio_set_multitap_analog(0); + sio_connect_pad(0); + sio_set_pad_negcon(0, 0x10, 0x20, 0x30); + sio_set_pad_sticks(0, 0x44, 0x80, 0x80, 0x80); + sio_request_pad_type(0, SIO_PAD_NEGCON); + (void)xchg(0, 0x01); + EXPECT("tap.forced.digital.id", 0x41, xchg(0, 0x42)); + (void)xchg(0, 0x00); (void)xchg(0, 0x00); (void)xchg(0, 0x00); + EXPECT("tap.forced.digital", SIO_PAD_DIGITAL, sio_get_pad_analog(0)); + + sio_set_multitap_analog(1); + sio_request_pad_type(0, SIO_PAD_NEGCON); + /* Slot A poll with TAP=1 applies the request and arms the bulk read. */ + (void)xchg(0, 0x01); + EXPECT("tap.hack.slot_a.id", 0x23, xchg(0, 0x42)); + EXPECT("tap.hack.negcon", SIO_PAD_NEGCON, sio_get_pad_analog(0)); + (void)xchg(0, 0x01); + for (int i = 0; i < 6; i++) (void)xchg(0, 0x00); + (void)xchg(0, 0x01); + r[0] = xchg(0, 0x42); + for (int i = 1; i < 34; i++) r[i] = xchg(0, 0x00); + EXPECT("tap.bulk.id", 0x80, r[0]); + EXPECT("tap.bulk.a.id", 0x23, r[2]); + EXPECT("tap.bulk.a.twist", 0x44, r[6]); + EXPECT("tap.bulk.a.I", 0x10, r[7]); + EXPECT("tap.bulk.a.II", 0x20, r[8]); + EXPECT("tap.bulk.a.L", 0x30, r[9]); + sio_set_multitap(0); +} +#endif + +int main(void) { + negcon_wire(); + lock_and_swap(); + deferred_during_config(); + set_mode_keeps_device(); + snapshot_pad_mode(); +#if PSX_MAX_PLAYERS >= 5 + multitap(); +#endif + if (failures) { + fprintf(stderr, "NeGcon SIO protocol (players=%d): %d failure(s)\n", + PSX_MAX_PLAYERS, failures); + return 1; + } + fprintf(stderr, "NeGcon SIO protocol (players=%d): passed\n", PSX_MAX_PLAYERS); + return 0; +}