diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 775e3075b..994a7a5d3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,14 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(digital_pad_snapshot_test tests/test_digital_pad_snapshot.c) + target_include_directories(digital_pad_snapshot_test PRIVATE include) + if(MSVC) + target_compile_options(digital_pad_snapshot_test PRIVATE /UNDEBUG) + else() + target_compile_options(digital_pad_snapshot_test PRIVATE -UNDEBUG) + endif() + add_test(NAME digital_pad_snapshot_test COMMAND digital_pad_snapshot_test) add_executable(gpu_fmv_hd_test tests/test_gpu_fmv_hd.cpp src/gpu_fmv_hd.c) target_include_directories(gpu_fmv_hd_test PRIVATE include ${PSX_SDL_INCLUDE_DIRS}) target_compile_definitions(gpu_fmv_hd_test PRIVATE SDL_MAIN_HANDLED PSX_FMV_HD_TESTING=1 diff --git a/runtime/include/digital_pad_snapshot.h b/runtime/include/digital_pad_snapshot.h new file mode 100644 index 000000000..f172355c4 --- /dev/null +++ b/runtime/include/digital_pad_snapshot.h @@ -0,0 +1,51 @@ +#ifndef PSX_DIGITAL_PAD_SNAPSHOT_H +#define PSX_DIGITAL_PAD_SNAPSHOT_H +#include +#include +#include "mod_plugins.h" + +/* Host-only observations. Never serialized with the emulated SIO machine. */ +typedef struct PSXResolvedDigitalPad { + uint32_t held, generation, press[16]; +} PSXResolvedDigitalPad; +static inline void digital_pad_history_reset(PSXResolvedDigitalPad *s,uint16_t active_low) { + uint32_t generation=s->generation+1u; + memset(s,0,sizeof *s);s->generation=generation?generation:1u; + s->held=(uint16_t)~active_low; +} +static inline void digital_pad_history_record(PSXResolvedDigitalPad *s,uint16_t active_low) { + uint32_t held=(uint16_t)~active_low,pressed=held & ~s->held; + for(unsigned i=0;i<16;++i)if(pressed & (1u<press[i]; + s->held=held; +} +static inline void digital_pad_reader_reset(PSXModDigitalPadReader *r) { + memset(r,0,sizeof *r);r->struct_size=sizeof *r; +} +static inline int digital_pad_reader_read(PSXModDigitalPadReader *r,const PSXResolvedDigitalPad *s, + uint64_t frame,uint16_t legacy,uint32_t *out) { + if(!r || !s || !out || r->struct_size!=sizeof *r)return 0; + uint32_t held=0; + if(!r->valid || r->generation!=s->generation || frameframe || frame-r->frame>1)goto prime; + if(frame==r->frame) {*out=r->held;return 1;} + for(unsigned i=0;i<16;++i) { + uint32_t delta=s->press[i]-r->last_press[i]; + if(delta>4 || r->pending[i]>4-delta)goto prime; + } + for(unsigned i=0;i<16;++i) { + const uint32_t bit=1u<pending[i]+=s->press[i]-r->last_press[i];r->last_press[i]=s->press[i]; + if(r->held & bit) { + /* A second real press implies an intervening release. Deliver + * that release first, not a duplicate held/press frame. */ + if((s->held & bit) && !r->pending[i])held|=bit; + } else if(r->pending[i]) { + held|=bit;--r->pending[i]; + } else if(s->held & bit)held|=bit; + } + r->held=held;r->frame=frame;*out=held;return 1; +prime: + digital_pad_reader_reset(r);r->generation=s->generation;r->frame=frame; + r->held=legacy;r->valid=1;memcpy(r->last_press,s->press,sizeof r->last_press); + return 0; +} +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 6cda240ae..e13979a87 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1195,6 +1195,27 @@ typedef struct PSXModControllerState { } PSXModControllerState; typedef int (*PSXModControllerSource)(PSXModControllerState *state); int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); + +/* Optional low-latency digital reader. Reads already resolved local SIO input; + * never polls SDL, writes a packet, changes controller routing or runs guest + * input/rumble code. Main thread, standalone offline digital P1 only. Other + * controller policies/sources, UI/input guards, resim and multitap decline. + * Caller initializes only struct_size (other fields zero), keeps one reader + * per input-consumer and does not persist/copy it into a savestate. Repeated + * reads within a guest frame are idempotent. Captured short taps are latched + * until the next original-rate read, with release/press separation; a skipped + * frame, device/session generation change or excessive backlog re-primes from + * legacy_buttons rather than replaying old actions. Return 0: keep the full + * original read result. Return 1: buttons receives active-high 16-bit buttons. + * This does not increase guest input/simulation frequency. */ +typedef struct PSXModDigitalPadReader { + uint32_t struct_size, generation, held, valid; + uint64_t frame; + uint32_t last_press[16], pending[16]; /* private reader state */ +} PSXModDigitalPadReader; +int psx_mod_read_digital_pad(uint32_t player, PSXModDigitalPadReader *reader, + uint32_t legacy_buttons, uint32_t *buttons); + /* Presence of an attached offline source (default connected). A diagnostic * source may explicitly emulate an unplugged pad without falling back to a * physical controller assigned to that port. Cleared on detach/session reset. */ diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 8a8f0468f..4fbf5ae25 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -210,6 +210,8 @@ void sio_get_pad_jogcon_motor(int slot, uint8_t *command, uint8_t *strength); * pad 0 .. PSX_MAX_PLAYERS-1. */ uint16_t sio_get_pad_buttons(void); uint16_t sio_get_pad_buttons_slot(int slot); +struct PSXResolvedDigitalPad; +int sio_get_resolved_digital_pad(int slot, struct PSXResolvedDigitalPad *out); /* Side-effect-FREE SIO register peeks for the debug/observability path ONLY. * NEVER use these for the guest bus — the guest must call sio_read() (which keeps diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4585d14d1..7c196c556 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -95,6 +95,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ #include "psx_trigger.h" /* continuous SDL trigger -> 0..255 magnitude */ #include "mod_pad_transform.h" +#include "digital_pad_snapshot.h" #include "psx_hotkey_pad.h" /* host shortcut bindings, direct claims */ #include "psx_stick_fold.h" /* default-map digital stick fold (host layer) */ #include "psx_controller_type.h" /* mapped wheel-name classification */ @@ -9132,6 +9133,27 @@ static bool local_mouse_live(int override_word) { return pad_ext_live(&g) != 0; } +extern "C" int psx_mod_read_digital_pad(uint32_t player,PSXModDigitalPadReader *reader, + uint32_t legacy_buttons,uint32_t *buttons) { + if(!reader || reader->struct_size!=sizeof *reader || !buttons)return 0; +#ifndef PSX_NO_DEBUG_TOOLS + if(debug_server_get_input_override()>=0) { + digital_pad_reader_reset(reader);return 0; + } +#endif + if(player!=0 || g_offline_pad_count!=1 || !local_mouse_live(-1) || + psx_local_mouse_installed() || sio_get_multitap() || + mod_controller_source_present(player) || mod_pad_transform_initial_type(player)>=0 || + g_mod_controller_policy[player].callback || g_mod_controller_mode_override[player]>=0) { + digital_pad_reader_reset(reader);return 0; + } + PSXResolvedDigitalPad sample{}; + if(!sio_get_resolved_digital_pad((int)player,&sample)) { + digital_pad_reader_reset(reader);return 0; + } + return digital_pad_reader_read(reader,&sample,s_frame_count,(uint16_t)legacy_buttons,buttons); +} + static NetplayVblankEpilogue sdl_vblank_present_body(void) { NetplayVblankEpilogue ep{}; /* Guest quantum for this vblank is complete. Drop top-level-resume armed diff --git a/runtime/src/sio.c b/runtime/src/sio.c index e85f8b46d..8383e9cd5 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -13,6 +13,8 @@ */ #include "sio.h" +#include "digital_pad_snapshot.h" +#include "psx_cycle_freeze.h" #include "psx_bss.h" #include "memcard.h" #include "debug_server.h" @@ -46,6 +48,7 @@ static void sio_debug_poll_maybe(void) { /* Pad state: 0=pressed, 1=released (PS1 convention). Indexed by LOGICAL pad * 0 .. PSX_MAX_PLAYERS-1 (not physical SIO slot). */ static uint16_t pad_buttons[PSX_MAX_PLAYERS] = { PSX_PAD_INIT(0xFFFF) }; +static PSXResolvedDigitalPad pad_input_history[PSX_MAX_PLAYERS]; /* 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; NeGcon @@ -864,6 +867,7 @@ void sio_init(void) { for (int i = 0; i < PSX_MAX_PLAYERS; i++) { pad_analog_device[i] = SIO_PAD_DUALSHOCK; pad_buttons[i] = 0xFFFF; + digital_pad_history_reset(&pad_input_history[i],pad_buttons[i]); pad_analog[i] = 0; pad_stick[i][0] = pad_stick[i][1] = pad_stick[i][2] = pad_stick[i][3] = 0x80; pad_in_config[i] = 0; @@ -1059,6 +1063,8 @@ void sio_netplay_canonicalize_session_pads(int slot_count) void sio_set_pad_connected(int slot, int connected) { if (slot < 0 || slot >= PSX_MAX_PLAYERS) return; + if (!!connected != !!(pad_connected & (1u<= 0 && slot < PSX_MAX_PLAYERS) pad_buttons[slot] = buttons; + if (slot >= 0 && slot < PSX_MAX_PLAYERS) { + digital_pad_history_record(&pad_input_history[slot],buttons); + pad_buttons[slot] = buttons; + } } /* Entering or leaving NeGcon swaps the controller on the port; it is not the @@ -1127,6 +1136,7 @@ static int pad_type_is_device_swap(uint8_t from, uint8_t to) { static void pad_commit_type(int slot, uint8_t type) { const uint8_t from = pad_analog[slot]; if (from != type) { + digital_pad_history_reset(&pad_input_history[slot],pad_buttons[slot]); pad_jogcon_last_steering[slot] = 0; pad_jogcon_motor_command[slot] = 0; pad_jogcon_motor_strength[slot] = 0; @@ -1219,6 +1229,12 @@ uint16_t sio_get_pad_buttons(void) { return pad_buttons[0]; } +int sio_get_resolved_digital_pad(int slot,PSXResolvedDigitalPad *out) { + if(!out || slot<0 || slot>=PSX_MAX_PLAYERS || !sio_get_pad_connected(slot) || + sio_get_pad_analog(slot)!=SIO_PAD_DIGITAL)return 0; + *out=pad_input_history[slot];return 1; +} + uint16_t sio_get_pad_buttons_slot(int slot) { return (slot >= 0 && slot < PSX_MAX_PLAYERS) ? pad_buttons[slot] : 0xFFFF; } @@ -3084,6 +3100,11 @@ void sio_snapshot_section_ends(uint32_t out[5]) { } static int sio_snap_parse(PstR *r) { + /* A real state load invalidates input history; restoring a render-only + * checkpoint must not discard host presses captured by normal polling. */ + if (!g_psx_render_pass_active) + for(int slot=0;slot +#include +#include "digital_pad_snapshot.h" +int main(void) { + PSXResolvedDigitalPad s={0};PSXModDigitalPadReader r={sizeof r};uint32_t out=0; + digital_pad_history_reset(&s,0xffff);assert(!digital_pad_reader_read(&r,&s,0,0,&out)); + /* A complete short tap between original game reads is not lost. */ + digital_pad_history_record(&s,0xffdf);digital_pad_history_record(&s,0xffff); + assert(digital_pad_reader_read(&r,&s,1,0,&out) && out==0x20); + assert(digital_pad_reader_read(&r,&s,1,0,&out) && out==0x20); /* same frame */ + assert(digital_pad_reader_read(&r,&s,2,0x20,&out) && !out); /* delayed packet cannot replay it */ + assert(digital_pad_reader_read(&r,&s,3,0,&out) && !out); + /* Held input is never turned into repeated presses; release is prompt. */ + digital_pad_history_record(&s,0xffbf); + assert(digital_pad_reader_read(&r,&s,4,0,&out) && out==0x40); + for(unsigned f=5;f<10;++f) { + digital_pad_history_record(&s,0xffbf); + assert(digital_pad_reader_read(&r,&s,f,0x40,&out) && out==0x40); + } + digital_pad_history_record(&s,0xffff); + assert(digital_pad_reader_read(&r,&s,10,0x40,&out) && !out); + /* Two captured taps are separated into two presses and two releases. */ + for(int i=0;i<2;++i) {digital_pad_history_record(&s,0x7fff);digital_pad_history_record(&s,0xffff);} + for(unsigned f=11;f<15;++f) { + assert(digital_pad_reader_read(&r,&s,f,0,&out));assert(out==((f&1)?0x8000:0)); + } + /* Scene/load gaps and device changes never replay accumulated actions. */ + digital_pad_history_record(&s,0xffdf);digital_pad_history_record(&s,0xffff); + assert(!digital_pad_reader_read(&r,&s,20,0,&out)); + assert(digital_pad_reader_read(&r,&s,21,0,&out) && !out); + digital_pad_history_reset(&s,0xffff);assert(!digital_pad_reader_read(&r,&s,22,0,&out)); + /* All 16 button bits, including sign bit, remain independent. */ + for(unsigned bit=0;bit<16;++bit) { + digital_pad_history_record(&s,(uint16_t)~(1u< 0 ? 1 : 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active; diff --git a/runtime/tests/test_sio_digital_command.c b/runtime/tests/test_sio_digital_command.c index 0293b4e71..7e8436e85 100644 --- a/runtime/tests/test_sio_digital_command.c +++ b/runtime/tests/test_sio_digital_command.c @@ -1,6 +1,7 @@ /* Plain digital pads shift their ID during every command byte. * Synthetic bus transactions over the real sio.c; no BIOS/disc/core fixture. */ #include "sio.h" +#include "digital_pad_snapshot.h" #include #include #include @@ -94,7 +95,39 @@ static void setup(unsigned slot) { sio_set_pad_config_capable(slot, 0); sio_set_pad_state_slot(slot, 0xABCD); } +extern int g_psx_render_pass_active; +static void test_host_history(void) { + setup(0); + sio_set_pad_state_slot(0,0xffff); + PSXResolvedDigitalPad a,b; + expect("digital snapshot available",1,sio_get_resolved_digital_pad(0,&a)); + sio_set_pad_state_slot(0,0xffdf);sio_set_pad_state_slot(0,0xffff); + expect("released snapshot available",1,sio_get_resolved_digital_pad(0,&b)); + expect("short tap counted",a.press[5]+1,b.press[5]); + expect("short tap released",0,b.held); + uint32_t size=sio_snapshot_bytes();uint8_t *bytes=malloc(size); + if(!bytes)exit(1); + sio_snapshot_write(bytes); + a=b;g_psx_render_pass_active=1; + expect("render checkpoint restore",1,sio_snapshot_read(bytes,size)); + g_psx_render_pass_active=0; + expect("checkpoint history available",1,sio_get_resolved_digital_pad(0,&b)); + expect("checkpoint preserves history",a.generation,b.generation); + expect("checkpoint preserves press",a.press[5],b.press[5]); + expect("real state restore",1,sio_snapshot_read(bytes,size));free(bytes); + expect("state-load history available",1,sio_get_resolved_digital_pad(0,&b)); + expect("state load invalidates reader",1,a.generation!=b.generation); + sio_set_pad_connected(0,0); + expect("disconnected has no snapshot",0,sio_get_resolved_digital_pad(0,&b)); + sio_set_pad_connected(0,1); + expect("reconnected snapshot available",1,sio_get_resolved_digital_pad(0,&b)); + expect("reconnect advances generation",1,a.generation!=b.generation); + sio_set_pad_type(0,SIO_PAD_DUALSHOCK,128,128,128,128); + expect("analog has no digital snapshot",0,sio_get_resolved_digital_pad(0,&b)); +} + int main(void) { + test_host_history(); for (unsigned slot = 0; slot < 2; ++slot) { for (unsigned cmd = 0; cmd < 256; ++cmd) { setup(slot); @@ -155,3 +188,9 @@ int main(void) { printf("digital command: %u checks, %u failures\n", checks, failures); return failures ? 1 : 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active; + +/* Match the current SIO flush-status query in this no-file fixture. */ +int memcard_last_flush_result(int card) { (void)card;return 0; } diff --git a/runtime/tests/test_sio_dualshock_rumble.c b/runtime/tests/test_sio_dualshock_rumble.c index 65cf5cf14..503dd0849 100644 --- a/runtime/tests/test_sio_dualshock_rumble.c +++ b/runtime/tests/test_sio_dualshock_rumble.c @@ -201,3 +201,9 @@ int main(void) { fprintf(stderr, "DualShock rumble protocol: passed\n"); return 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active; + +/* Match the current SIO flush-status query in this no-file fixture. */ +int memcard_last_flush_result(int card) { (void)card;return 0; } diff --git a/runtime/tests/test_sio_jogcon_protocol.c b/runtime/tests/test_sio_jogcon_protocol.c index e49a90f30..3f92cb4e1 100644 --- a/runtime/tests/test_sio_jogcon_protocol.c +++ b/runtime/tests/test_sio_jogcon_protocol.c @@ -308,3 +308,9 @@ int main(void) { fprintf(stderr, "JogCon SIO protocol: passed\n"); return 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active; + +/* Match the current SIO flush-status query in this no-file fixture. */ +int memcard_last_flush_result(int card) { (void)card;return 0; } diff --git a/runtime/tests/test_sio_negcon_protocol.c b/runtime/tests/test_sio_negcon_protocol.c index 94d5304ef..ef398e268 100644 --- a/runtime/tests/test_sio_negcon_protocol.c +++ b/runtime/tests/test_sio_negcon_protocol.c @@ -362,3 +362,9 @@ int main(void) { fprintf(stderr, "NeGcon SIO protocol (players=%d): passed\n", PSX_MAX_PLAYERS); return 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active; + +/* Match the current SIO flush-status query in this no-file fixture. */ +int memcard_last_flush_result(int card) { (void)card;return 0; } diff --git a/runtime/tests/test_sio_spu_istat_ownership.c b/runtime/tests/test_sio_spu_istat_ownership.c index b64f249f3..6ca661a9b 100644 --- a/runtime/tests/test_sio_spu_istat_ownership.c +++ b/runtime/tests/test_sio_spu_istat_ownership.c @@ -156,3 +156,6 @@ int main(void) { printf("ALL PASS (%d checks)\n", g_checks); return 0; } + +/* Standalone device test: not executing a render-only checkpoint. */ +int g_psx_render_pass_active;