diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index d7679aa0..3e30d4f9 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -443,6 +443,20 @@ id = "r4.widescreen" netplay = "local_view" ``` +Two more plugin classes stay on per player. `netplay = "input"` transforms +only this player's pad before it is staged (`psx_mod_set_pad_transform`). +`netplay = "host_output"` only reads guest state to drive this player's host +output (rumble, haptics): its function-entry hooks run in the shared +simulation (including rollback resimulation) on a private copy of the CPU +state, whose register changes are discarded; a completion request from them +is ignored; and every guest-mutating mod service (`psx_mod_write_*`, +`psx_mod_host_write_ram`, `psx_mod_call_guest*`, DMA / SPU / VRAM uploads, +guest allocations, the cycle-scale gate) refuses while one runs +(`psx_mod_host_output_scope()`), so the shared simulation is unchanged by +them. This is a determinism contract for trusted plugins, not a sandbox. Host-side calls that must not +repeat on a resim (e.g. `psx_mod_set_host_rumble`) check +`psx_netplay_is_resimulating()` themselves. + At a netplay session start the runtime resolves the player's own selection and keeps only features whose every contribution is such a plugin (no EXE or disc write, overlay or derived disc; `mod_runtime_commit_netplay_view`). They diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index fa9f8b6f..a902ad75 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1263,7 +1263,8 @@ if(BUILD_TESTING) src/cue_sheet.cpp src/iso_reader.cpp src/crc32.c - src/psx_sha256.c) + src/psx_sha256.c + src/sio.c) target_include_directories(netplay_view_mods_test PRIVATE include ../recompiler/include diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index d989083e..7e9117b7 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -223,6 +223,11 @@ struct ModPlugin { * before it is staged (psx_mod_set_pad_transform). Online it stays on per * player: the post-transform pad is what every peer simulates. */ bool netplay_input = false; + /* netplay = "host_output": the plugin only reads guest state and drives + * host output (rumble, haptics). Online it stays on per player and its + * hooks run in the shared simulation, but every guest write it attempts + * is refused there, so it can never diverge the peers. */ + bool netplay_host_output = false; }; struct ModResource { @@ -388,6 +393,7 @@ struct ModResolution { std::string feature_id; bool netplay_local_view = false; bool netplay_input = false; + bool netplay_host_output = false; }; std::vector plugins; struct Resource { diff --git a/runtime/include/sio.h b/runtime/include/sio.h index dab3ee58..9b396b74 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -215,6 +215,8 @@ uint16_t sio_get_pad_buttons_slot(int slot); uint16_t sio_peek_stat(void); uint16_t sio_peek_ctrl(void); uint8_t sio_peek_rx_data(void); +/* SIO_STAT reads left before a visible ACK clears (side-effect free). */ +int sio_peek_ack_visible_reads(void); /* Debug accessors: is a logical pad connected, and is it in analog mode. */ int sio_get_pad_connected(int slot); diff --git a/runtime/src/mod_guest_services.c b/runtime/src/mod_guest_services.c index 57c80a78..a15282bf 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -11,9 +11,14 @@ #include "spu.h" #include +/* mod_runtime.cpp: 1 while a netplay host-output plugin's hook runs; every + * guest-mutating service refuses then (docs/NETPLAY.md). */ +int psx_mod_host_output_scope(void); + uint32_t psx_mod_call_guest(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3) { + if (psx_mod_host_output_scope()) return 0; uint32_t regs[32]; const uint32_t pc = cpu->pc, hi = cpu->hi, lo = cpu->lo; memcpy(regs, cpu->gpr, sizeof regs); @@ -67,6 +72,11 @@ uint32_t psx_mod_call_guest_uncharged_counted(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t budget_cycles, int* charged, uint64_t* counted_cycles) { + if (psx_mod_host_output_scope()) { + if (charged) *charged = 0; + if (counted_cycles) *counted_cycles = 0; + return 0; + } PsxCycleFreeze save; CallerTiming timing; timing_save(&timing, cpu); @@ -85,12 +95,14 @@ uint32_t psx_mod_call_guest_uncharged(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t budget_cycles, int* charged) { + if (psx_mod_host_output_scope()) { if (charged) *charged = 0; return 0; } return psx_mod_call_guest_uncharged_counted(cpu, function, return_address, a0, a1, a2, a3, budget_cycles, charged, NULL); } int psx_mod_dma_write_ram(uint32_t address, const void* data, uint32_t bytes, int lba) { + if (psx_mod_host_output_scope()) return 0; return dma_host_cdrom_write(address, (const uint8_t*)data, bytes, lba); } @@ -101,6 +113,7 @@ static int ram_span(uint32_t address, uint32_t bytes) { } int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes) { + if (psx_mod_host_output_scope()) return 0; const uint8_t* p = (const uint8_t*)data; if ((!p && bytes) || !ram_span(address, bytes)) return 0; while (bytes && (address & 3u)) { psx_host_write_byte(address++, *p++); --bytes; } @@ -118,22 +131,30 @@ int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes) { #define SPU_CONTROL 0x1F801DAAu int psx_mod_spu_sample_bank(uint32_t bank,const void *adpcm,uint32_t bytes) { + if (psx_mod_host_output_scope()) return 0; return spu_register_sample_bank(bank,adpcm,bytes); } int psx_mod_spu_bind_voice_bank(unsigned voice,uint32_t bank) { + if (psx_mod_host_output_scope()) return 0; return spu_bind_next_voice_bank(voice,bank); } int psx_mod_spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode) { + if (psx_mod_host_output_scope()) return 0; return spu_private_voice_play(slot,bank,regs,mode); } -void psx_mod_spu_private_voice_stop(uint32_t mask) {spu_private_voice_stop(mask);} +void psx_mod_spu_private_voice_stop(uint32_t mask) { + if (psx_mod_host_output_scope()) return; + spu_private_voice_stop(mask); +} int psx_mod_spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right) { + if (psx_mod_host_output_scope()) return 0; return spu_private_voice_volume(slot,left,right); } uint32_t psx_mod_spu_private_voice_active(void) {return spu_private_voice_active();} int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, uint32_t bytes, int stop_after) { + if (psx_mod_host_output_scope()) return 0; if ((spu_address & 7u) || (guest_source & 3u) || (bytes & 3u) || spu_address >= SPU_RAM_BYTES || bytes > SPU_RAM_BYTES || !ram_span(guest_source, bytes)) @@ -148,6 +169,7 @@ int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, int psx_mod_psyq_load_image(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t guest_source) { + if (psx_mod_host_output_scope()) return 0; if (!w || w > 1024u || !h || h > 512u || x >= 1024u || y >= 512u || (guest_source & 3u)) return 0; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 35979a73..7138cc3d 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2599,11 +2599,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, const std::string netplay = toml::find_or(v, "netplay", ""); if (!netplay.empty() && netplay != "local_view" && - netplay != "input") + netplay != "input" && netplay != "host_output") throw std::runtime_error( - "plugin netplay must be \"local_view\" or \"input\""); + "plugin netplay must be \"local_view\", \"input\" " + "or \"host_output\""); plugin.netplay_local_view = netplay == "local_view"; plugin.netplay_input = netplay == "input"; + plugin.netplay_host_output = netplay == "host_output"; } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); @@ -3945,6 +3947,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.feature_id = plugin->feature_id; resolved.netplay_local_view = plugin->netplay_local_view; resolved.netplay_input = plugin->netplay_input; + resolved.netplay_host_output = plugin->netplay_host_output; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 343186b9..92eab572 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2291,7 +2291,8 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& for (const ModResolution::Plugin& p : plan.plugins) { const std::string key = p.package_id + "/" + p.feature_id; auto it = ok.find(key); - const bool good = p.netplay_local_view || p.netplay_input; + const bool good = p.netplay_local_view || p.netplay_input || + p.netplay_host_output; ok[key] = it == ok.end() ? good : (it->second && good); } for (const ModResolution::Write& w : plan.writes) @@ -2911,7 +2912,27 @@ extern "C" int psx_mod_reload_hd_texture_pack(void) { return ok; } +/* Host-output observation reads: side-effect-free peeks of RAM, scratchpad + * and BIOS ROM (psx_peek_word); MMIO and anything else reads 0, because a bus + * read of a device register can change the device (SIO data pops the RX FIFO, + * SIO_STAT ages the ACK). */ +extern "C" int psx_mod_host_output_scope(void); +static uint32_t ho_peek_word(uint32_t address) { + uint32_t v = 0; + return psx_peek_word(address & ~3u, &v) ? v : 0u; +} +static uint8_t ho_peek_byte(uint32_t address) { + return (uint8_t)(ho_peek_word(address) >> ((address & 3u) * 8u)); +} +static uint16_t ho_peek_half(uint32_t address) { + return (uint16_t)(ho_peek_word(address) >> ((address & 2u) * 8u)); +} +static void ho_drop_word(uint32_t, uint32_t) {} +static void ho_drop_half(uint32_t, uint16_t) {} +static void ho_drop_byte(uint32_t, uint8_t) {} + extern "C" uint8_t psx_mod_read_byte(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_byte(address); return psx_read_byte(address); } @@ -2936,38 +2957,50 @@ extern "C" int psx_mod_current_resource_bytes(const char* resource_id, return 0; } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_byte(address, value); } extern "C" uint16_t psx_mod_read_half(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_half(address); return psx_read_half(address); } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_half(uint32_t address, uint16_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_half(address, value); } extern "C" uint32_t psx_mod_read_word(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_word(address); return psx_read_word(address); } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_word(uint32_t address, uint32_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_word(address, value); } extern "C" void psx_mod_write_code_word(uint32_t address, uint32_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_word(address, value); dirty_ram_mark_executable_range(address & 0x1FFFFFFFu, 4u); } +extern "C" int psx_mod_host_output_scope(void); extern "C" uint32_t psx_mod_alloc_guest_memory(uint32_t size, uint32_t alignment) { + if (psx_mod_host_output_scope()) return 0; return psx_mod_memory_alloc(size, alignment); } extern "C" uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size, uint32_t alignment) { + if (psx_mod_host_output_scope()) return 0; return psx_mod_gpu_dma_memory_alloc(size, alignment); } @@ -3084,6 +3117,7 @@ extern "C" int psx_mod_register_function_filter_plugin( extern "C" int psx_mod_finish_function(CPUState* cpu) { using namespace PSXRecompV4; RuntimeMods& s = state(); + if (psx_mod_host_output_scope()) return 0; if (!cpu || s.current_function_cpu != cpu || !s.current_plugin) return 0; s.current_function_finished = true; return 1; @@ -3141,11 +3175,62 @@ extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t in static int game_netplay_function_entry(CPUState* cpu, uint32_t address); +/* Netplay host-output plugins ([[plugin]] netplay = "host_output") run their + * entry hooks in the shared simulation while the rest of the own-view plan is + * parked. They observe only: each callback gets a private copy of the CPU + * state (register changes to it are discarded; its bus pointers peek RAM / + * ROM and drop writes), psx_mod_read_* peek too (no MMIO side effects), a + * completion request is + * ignored, and every guest-mutating mod service refuses while one runs + * (psx_mod_host_output_scope), so the shared simulation is bit-identical + * with or without them. */ +static int s_host_output_depth; + +extern "C" int psx_mod_host_output_scope(void) { + return s_host_output_depth > 0 && + PSXRecompV4::state().netplay_view_plan ? 1 : 0; +} + +static void run_host_output_hooks(CPUState* cpu, uint32_t address) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + const auto& table = active_function_entry_hooks(); + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound( + table.begin(), table.end(), key, + [](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; }); + for (; it != table.end() && it->key == key; ++it) { + if (!it->plugin || !it->plugin->netplay_host_output || !it->callback) + continue; + CPUState view = *cpu; /* observation copy: peek reads, dropped writes */ + view.read_word = ho_peek_word; view.write_word = ho_drop_word; + view.read_half = ho_peek_half; view.write_half = ho_drop_half; + view.read_byte = ho_peek_byte; view.write_byte = ho_drop_byte; + PluginCallbackScope scope(s, it->plugin, &view); + ++s_host_output_depth; + it->callback(&view, address); + --s_host_output_depth; + s.current_function_finished = false; + } +} + +/* Memory writes from a host-output plugin while a netplay plan is live would + * exist on one peer only. */ +static bool mod_host_output_write_blocked(void) { + const auto& s = PSXRecompV4::state(); + return psx_mod_host_output_scope() || + (s.netplay_view_plan && s.current_plugin && + s.current_plugin->netplay_host_output); +} + extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_function_entry_hooks || !cpu) return 0; RuntimeMods& s = state(); - if (mod_plan_hooks_parked()) return game_netplay_function_entry(cpu, address); + if (mod_plan_hooks_parked()) { + run_host_output_hooks(cpu, address); + return game_netplay_function_entry(cpu, address); + } const auto& table = active_function_entry_hooks(); const uint32_t key = function_entry_key(address); auto it = std::lower_bound( diff --git a/runtime/src/mod_texture_banks.c b/runtime/src/mod_texture_banks.c index d5767db1..d58d9f0f 100644 --- a/runtime/src/mod_texture_banks.c +++ b/runtime/src/mod_texture_banks.c @@ -54,8 +54,10 @@ const uint16_t* mod_texture_bank_pixels(uint16_t id, uint32_t* w, uint32_t* h) { return banks[id].pixels; } +int psx_mod_host_output_scope(void); uint32_t psx_mod_alloc_texture_packet_memory(uint32_t size, uint32_t alignment) { uint32_t addr; + if (psx_mod_host_output_scope()) return 0; if (arena_count == 16u || size < 40u) return 0; addr = psx_mod_gpu_dma_memory_alloc(size, alignment); if (!addr) return 0; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index df936415..a4e39015 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -104,7 +104,11 @@ void psx_guest_cycle_scale_vblank(void) { } /* Public mod API (mod_plugins.h): the mod gate. */ -void psx_mod_set_guest_cycle_scale_gate(int open) { psx_guest_cycle_scale_gate_open(open); } +int psx_mod_host_output_scope(void); +void psx_mod_set_guest_cycle_scale_gate(int open) { + if (psx_mod_host_output_scope()) return; + psx_guest_cycle_scale_gate_open(open); +} uint32_t psx_mod_guest_cycle_scale(void) { return s_psx_gcs_live; } void psx_guest_cycle_scale_snapshot(uint32_t out[3]) { diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 67f6e3b9..e8ead460 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -2172,6 +2172,7 @@ uint32_t sio_read(uint32_t addr) { uint16_t sio_peek_stat(void) { return sio_stat; } uint16_t sio_peek_ctrl(void) { return sio_ctrl; } uint8_t sio_peek_rx_data(void) { return sio_rx_data; } +int sio_peek_ack_visible_reads(void) { return (int)sio_ack_visible_reads; } void sio_write(uint32_t addr, uint32_t value) { sio_debug_poll_maybe(); diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index 77f37ccb..85c31496 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -53,6 +53,8 @@ void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 86fe92b5..9a5e8350 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -14,6 +14,8 @@ uint64_t psx_cycle_count=0; static int test_full_composite; int gpu_ws_background_requires_full_composite(void){return test_full_composite;} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} static uint16_t image[1024*512], oracle[1024*512]; int g_psx_vram_dirty_tracking=0; diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index e979f8a0..32d276e7 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -49,6 +49,8 @@ void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index c1e1fd99..11f81fe2 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -84,6 +84,8 @@ #include "gl_batch_policy_hle.c" #include "gl_batch_policy_select.c" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index de3d90d9..f0706ba7 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -33,6 +33,8 @@ extern "C" uint16_t psx_read_half(uint32_t a) { extern "C" void psx_write_half(uint32_t a, uint16_t v) { psx_write_byte(a, (uint8_t)v); psx_write_byte(a + 1, (uint8_t)(v >> 8)); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t a) { return (uint32_t)psx_read_half(a) | (uint32_t)psx_read_half(a + 2) << 16; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 977f3fe9..0398f7d8 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -69,6 +69,8 @@ extern "C" void psx_write_half(uint32_t address, uint16_t value) { ram[offset + 1] = (uint8_t)(value >> 8); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t address) { const uint32_t offset = address & 0x1fffffu; return (uint32_t)ram[offset] | diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index 757fb06a..da38e817 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -1,6 +1,8 @@ #include "mod_runtime.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "sio.h" +#include #include "psx_sha256.h" #include "gpu.h" @@ -38,26 +40,71 @@ extern "C" int gpu_hd_textures_configure(const char*, int, int, char*, size_t) { extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { *out = {}; } extern "C" void gpu_hd_textures_set_dump_enabled(int) {} extern "C" int gpu_hd_textures_reload(char*, size_t) { return 0; } +/* sio.c's environment (as in test_sio_jogcon_protocol.c). */ +extern "C" { +uint32_t i_stat = 0, i_mask = 0; +uint32_t g_debug_current_func_addr = 0, g_debug_last_store_pc = 0; +int psx_get_in_exception(void) { return 0; } +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, uint32_t, uint8_t) {} +void starvation_ring_record(uint8_t, uint8_t, uint8_t, uint16_t, uint16_t, + uint8_t, uint8_t, uint16_t, uint8_t, uint16_t, + uint8_t, uint32_t) {} +void card_read_summary_record(uint8_t, uint8_t, uint16_t, uint8_t, uint8_t, + uint8_t, uint8_t, uint32_t) {} +void card_data_writes_arm(uint8_t, uint16_t, uint8_t, uint8_t) {} +void event_ring_record_aux(uint16_t, uint8_t, uint32_t) {} +void psx_irq_raise(uint32_t bit, uint32_t) { i_stat |= 1u << bit; } +int memcard_is_present(int) { return 0; } +int memcard_read_sector(int, int, uint8_t* buf) { std::memset(buf, 0, 128); return -1; } +int memcard_write_sector(int, int, const uint8_t*) { return -1; } +void memcard_flush(int) {} +} + +/* The guest bus: main RAM, plus the real SIO (sio.c) at 0x1F801040..5F, so + * a bus read of SIO_DATA / SIO_STAT has its hardware side effects here too. + * Every MMIO bus access is counted. */ +static int mmio_bus_accesses; +static bool is_sio(uint32_t a) { + a &= 0x1fffffffu; + return a >= 0x1F801040u && a < 0x1F801060u; +} extern "C" uint8_t psx_read_byte(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return (uint8_t)sio_read(address); } return ram[address & 0x1fffffu]; } extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } ram[address & 0x1fffffu] = value; } +/* Side-effect-free peek (memory.c): RAM only in this fixture. */ +extern "C" int psx_peek_word(uint32_t addr, uint32_t* out) { + if (is_sio(addr) || (addr & 0x1fffffffu) >= 0x200000u) return 0; + const uint32_t o = addr & 0x1ffffcu; + *out = (uint32_t)ram[o] | ((uint32_t)ram[o + 1] << 8) | + ((uint32_t)ram[o + 2] << 16) | ((uint32_t)ram[o + 3] << 24); + return 1; +} + extern "C" uint16_t psx_read_half(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return (uint16_t)sio_read(address); } const uint32_t offset = address & 0x1fffffu; return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); } extern "C" void psx_write_half(uint32_t address, uint16_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } const uint32_t offset = address & 0x1fffffu; ram[offset] = (uint8_t)value; ram[offset + 1] = (uint8_t)(value >> 8); } extern "C" uint32_t psx_read_word(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return sio_read(address); } const uint32_t offset = address & 0x1fffffu; return (uint32_t)ram[offset] | ((uint32_t)ram[offset + 1] << 8) | @@ -66,6 +113,7 @@ extern "C" uint32_t psx_read_word(uint32_t address) { } extern "C" void psx_write_word(uint32_t address, uint32_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } const uint32_t offset = address & 0x1fffffu; ram[offset] = (uint8_t)value; ram[offset + 1] = (uint8_t)(value >> 8); @@ -139,6 +187,28 @@ static void sim_entry(CPUState*, uint32_t) { sim_hits++; } static void view_activate(void) { view_activations++; } static void sim_activate(void) { sim_activations++; } static void view_vblank(void) { vblanks++; } +static int out_hits, out_activations, out_scope; +static uint32_t out_peeks[5]; +extern "C" int psx_mod_host_output_scope(void); +static void out_entry(CPUState* cpu, uint32_t) { + out_hits++; + out_scope = psx_mod_host_output_scope(); + cpu->gpr[2] = 0x12345678u; /* review repro: must not stick */ + /* review 2: MMIO reads through the mod API and the CPU's bus pointers, + * and stores through cpu->write_* (RAM and SIO_CTRL). */ + out_peeks[0] = psx_mod_read_word(0x1F801040u); /* SIO_DATA: pops RX */ + out_peeks[1] = psx_mod_read_half(0x1F801044u); /* SIO_STAT: ages ACK */ + out_peeks[2] = psx_mod_read_byte(0x80004000u); + if (cpu->read_word) out_peeks[3] = cpu->read_word(0x1F801040u); + if (cpu->read_half) out_peeks[4] = cpu->read_half(0x1F801044u); + if (cpu->write_word) cpu->write_word(0x80004004u, 0xDEADBEEFu); + if (cpu->write_half) cpu->write_half(0x1F80104Au, 0x0040u); /* SIO reset */ + if (cpu->write_byte) cpu->write_byte(0x80004008u, 0x77u); + cpu->pc = 0x80001234u; + psx_mod_write_byte(0x80004000u, 0x5Au); /* must be refused online */ + psx_mod_finish_function(cpu); /* must be ignored online */ +} +static void out_activate(void) { out_activations++; } static std::string package(const std::string& id, const std::string& feature, const std::string& plugin, const std::string& extra) { @@ -157,12 +227,15 @@ int main() { package("view.pkg", "wide", "view.wide", "netplay = \"local_view\"\n")); write_text(root / "bundled/sim.pkg/1.0.0/manifest.toml", package("sim.pkg", "cheat", "sim.cheat", "")); + write_text(root / "bundled/out.pkg/1.0.0/manifest.toml", + package("out.pkg", "rumble", "out.rumble", "netplay = \"host_output\"\n")); write_text(root / "bundled/bad.pkg/1.0.0/manifest.toml", package("bad.pkg", "x", "bad.x", "netplay = \"everywhere\"\n")); const std::string state = "format_version = 2\n" "[[feature]]\npackage_id = \"view.pkg\"\nid = \"wide\"\nenabled = true\n" - "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n"; + "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n" + "[[feature]]\npackage_id = \"out.pkg\"\nid = \"rumble\"\nenabled = true\n"; write_text(root / "state.toml", state); check(psx_mod_register_function_entry_plugin("view.wide", 0x80003000u, view_entry), @@ -173,6 +246,9 @@ int main() { psx_mod_register_activation_plugin("sim.cheat", sim_activate), "register activations"); check(psx_mod_register_vblank_plugin("view.wide", view_vblank), "register vblank"); + check(psx_mod_register_function_entry_plugin("out.rumble", 0x80003200u, out_entry) && + psx_mod_register_activation_plugin("out.rumble", out_activate), + "register host-output hook"); std::string error; check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-VIEW", 0x80002000u, {}, &error), @@ -186,6 +262,11 @@ int main() { psx_mod_function_entry(&cpu, 0x80003000u); psx_mod_function_entry(&cpu, 0x80003100u); check(view_hits == 1 && sim_hits == 1, "offline hooks run in the game"); + check(psx_mod_function_entry(&cpu, 0x80003200u) == 1 && out_hits == 1 && + psx_read_byte(0x80004000u) == 0x5Au && !out_scope, + "offline a host-output hook is an ordinary hook"); + cpu = CPUState{}; + psx_write_byte(0x80004000u, 0); /* Netplay: only the local-view feature remains, parked outside the scope. */ view_hits = sim_hits = view_activations = sim_activations = vblanks = 0; @@ -195,6 +276,54 @@ int main() { mod_runtime_activate_plugins(); check(view_activations == 1 && sim_activations == 0, "only the local-view plugin activates in a match"); + check(out_activations == 2, "the host-output plugin activates in a match too"); + out_hits = 0; + cpu = CPUState{}; + cpu.gpr[31] = 0x80005550u; + cpu.read_word = psx_read_word; cpu.write_word = psx_write_word; + cpu.read_half = psx_read_half; cpu.write_half = psx_write_half; + cpu.read_byte = psx_read_byte; cpu.write_byte = psx_write_byte; + /* A live pad exchange: select port 1, send 0x01, so SIO holds RX data + * and a pending ACK that a bus read would consume or age. */ + sio_init(); + sio_set_pad_connected(0, 1); + sio_write(0x1F80104Au, 0x1003u); /* TX_EN | SELECT | ACK_IRQ_EN, port 1 */ + sio_write(0x1F801040u, 0x01u); /* pad address byte */ + sio_tick(2000); /* exchange done: RX byte + ACK pending */ + check((sio_peek_stat() & 0x0002u) != 0, "fixture: RX_RDY set (pending RX byte)"); + check(sio_peek_ack_visible_reads() > 0, "fixture: ACK counter > 0"); + const int ack0 = sio_peek_ack_visible_reads(); + const uint16_t stat0 = sio_peek_stat(), ctrl0 = sio_peek_ctrl(); + const uint8_t rx0 = sio_peek_rx_data(); + psx_write_word(0x80004004u, 0x11111111u); + psx_write_byte(0x80004008u, 0x22u); + mmio_bus_accesses = 0; + const CPUState before = cpu; + check(psx_mod_function_entry(&cpu, 0x80003200u) == 0 && out_hits == 1, + "a host-output hook runs in the shared simulation, cannot finish it"); + check(out_scope == 1 && !psx_mod_host_output_scope(), + "host-output scope is set only while its hook runs"); + check(cpu.gpr[2] == 0 && std::memcmp(&cpu, &before, sizeof cpu) == 0, + "a host-output hook's CPU register changes do not reach the shared CPU"); + check(mmio_bus_accesses == 0, + "a host-output hook makes no MMIO bus access (reads or cpu->write_*)"); + check((sio_peek_stat() & 0x0002u) != 0, + "SIO RX_RDY still set: the hook did not pop the RX byte"); + check(sio_peek_ack_visible_reads() == ack0, + "SIO ACK counter unchanged: the hook's SIO_STAT reads did not age it"); + check(sio_peek_stat() == stat0 && sio_peek_ctrl() == ctrl0 && + sio_peek_rx_data() == rx0, + "SIO stat, ctrl and RX data unchanged by the hook"); + check(psx_read_word(0x80004004u) == 0x11111111u && + psx_read_byte(0x80004008u) == 0x22u, + "cpu->write_* from a host-output hook do not reach guest RAM"); + check(out_peeks[0] == 0 && out_peeks[1] == 0 && out_peeks[3] == 0 && + out_peeks[4] == 0, + "MMIO observation reads return 0 (no device access)"); + check(out_peeks[2] == psx_read_byte(0x80004000u), + "RAM observation reads still see guest RAM"); + check(psx_read_byte(0x80004000u) == 0, + "a host-output plugin's guest write is refused online"); psx_mod_function_entry(&cpu, 0x80003000u); psx_mod_function_entry(&cpu, 0x80003100u); mod_runtime_on_vblank(); diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index ad6529f3..6753677f 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -114,6 +114,8 @@ extern "C" void psx_write_half(uint32_t a, uint16_t v) { ram[o] = (uint8_t)v; ram[o + 1] = (uint8_t)(v >> 8); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t a) { const uint32_t o = a & 0x1fffffu; return (uint32_t)ram[o] | ((uint32_t)ram[o + 1] << 8) |