diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 7d20f832..e5653590 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -786,6 +786,19 @@ valid until the committed plan is replaced or cleared. Decode into host or enhancement memory and rebuild derived data when activation changes; never reopen the owner path as a substitute for the verified snapshot. +An activation callback can mount a slice of its verified resource for native CD +streaming with `psx_mod_append_disc_extent(id, byte_offset, sector_count, &lba)`. +The slice contains 2336-byte Mode-2 sectors beginning at the duplicated XA +subheader. The runtime appends extents after the mounted disc, synthesizes raw +sector headers and a data-track TOC/subchannel entry, and retains the original +CD-ROM/XA decoder, interrupts and timing. The returned LBAs are deterministic +for the same activation order. Registration rejects missing/unowned resources, +invalid ranges/subheaders, late calls and addresses beyond the CD MSF limit. +Plan replacement or netplay clearing removes the extents; base sectors remain +unchanged. The game plugin supplies its own file lookup, names and stream +selection. Donor bytes remain external and are covered by the resource fingerprint. + + Verified resource fingerprints include format, canonical size and SHA-256, rather than the selected path, so moving identical media or changing N64 byte order does not change save compatibility. Donor bytes are never package files, @@ -803,6 +816,23 @@ rejects encrypted entries and unsafe or absolute paths, limits archives to 4096 files and 256 MiB expanded size, stages extraction, validates the manifest, and publishes the version atomically. +### Guarded instruction callbacks + +Trusted plugins can register +`psx_mod_register_instruction_plugin(id, address, expected_word, callback)`. +List native code locations in `[recompiler] mod_instruction_sites` and regenerate. +Callbacks run immediately before the instruction; the registered full word, +executing word and current RAM word must agree. Segment aliases share a site. +Only active resolved plugins run, with their feature's resource context. +The dirty-RAM backend uses the same checks, including nested delay slots. +Overlay ABI v27 carries the forwarder; site metadata changes the codegen hash. + +Callbacks may change registers and data. They must preserve PC and must not +re-enter guest execution, because pending branch/load state is live. +`psx_mod_finish_function` is unavailable in this callback scope. Inactive plans +do not change guest behavior. Function entry replacement remains the separate +`psx_mod_register_function_entry_plugin` interface. + ### Presentation bezel packages The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 1e8ee91f..a3b7b9bf 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,57 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-09-30 (Phase 5 owner-requested source checkpoint):** Preserve the + existing V8:2 combined-content title and generic callback/disc-extent support + on local `checkpoint/v82-combined-20260930` branches. Source, tests and + attribution only; donor media, generated outputs and playtest evidence stay + external. Owner explicitly paused further implementation, including + widescreen, original music and netplay, pending a check-in. No publication. + +- **2026-09-30 (Phase 5 Dreamland mushroom freeze):** `beads-eio.12.5` + identified an imported projectile flag write altering its callback, followed + by a skipped prologue and invalid return into the BIOS exception loop. The + title converter now tracks the local projectile factory and target pointers. + Added 4163 relocation/flag property cases and an opt-in native mushroom-event + stress driver; the old overlay fails and the repaired overlay completes both + families. All 16 title checks pass. No framework behavior changes. + +- **2026-09-30 (Phase 5 Dreamland effect cleanup):** `beads-eio.12.2` traced + the owner's frozen V8:2 match to an imported projectile effect writing its + matrix over the native child link. The title's input-overlay converter now + recognizes both effect-factory object returns. Five corrected field accesses, + owner-media regression checks, complete overlay code generation and a live + explosion allocation/free check validate the repair. No framework execution, + timing or generated-source changes; owner handoff remains at 1x rendering. + +- **2026-09-30 (Phase 5 instruction callbacks):** `beads-kbj0` adds plan-owned, + exact-word-guarded callbacks before opt-in game instructions, including branch + and delay slots. Native/overlay metadata participates in the codegen hash; + dirty-RAM execution routes through the same guard after load retirement. + Overlay ABI v27 forwards the callback. Runtime, parser/emitter and interpreter + routing checks pass. V8:2 uses seven seams for independent vehicle stats, + upgrades, transformation parts and special dependencies. Chassey's live match, + input/pause and savestate roundtrip were observed after correcting the title + allocator to retain shrinking allocations, matching its source implementation. + +- **2026-09-30 (Phase 5 mod-defined callbacks):** `beads-eio.3.212` adds + trusted mod-defined guest functions in an unused address range, separate + from original function-entry hooks. Active plans own availability and + callback resource context. Generated BIOS dispatch handles these through + its normal return/stack contract, with no synthetic guest instructions. + Runtime tests cover aliases, collisions, activation/clearing and nested + callback scopes; emitter tests cover routing before hardware dispatch. + OpenBIOS/SCPH1001 regenerated; V8:2 title rebuilt. Imported content still + needs its title-side runtime/selection adapters (`beads-eio.12.1`). + +- **2026-09-30 (Phase 5 combined V8 project):** Owner selected USA 2nd Offense + as the game-code base while retaining psxrecomp as the runtime/recompiler. + New local `Vigilante82PSXRecomp` builds against `071847ab`, reaches a stock + match, responds to driving input and reloads a savestate in an isolated run. + The reference's thirteen original-car conversions roundtrip successfully; + imported runtime/selector adapters remain title work (`beads-eio.12.1`). + No generic execution or hardware change was needed for this bring-up. + - **2026-09-30 (Phase 5 callback reentrancy):** Plugin activation, VBlank and function-entry callbacks now use the same scoped resource/completion context. A native call inside an entry hook can deliver VBlank; previously those diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 802aab7d..f8952672 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -66,6 +66,7 @@ struct CodeGenConfig { // Trusted game-mod entry hooks ([recompiler] mod_function_entry_funcs). // Only explicitly listed guest functions call the runtime dispatcher. std::set mod_function_entry_funcs; + std::set mod_instruction_sites; // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest // addresses (MotK VLC leaves, etc.). Host locality hint only. @@ -426,6 +427,8 @@ private: // Instruction translation std::string translate_instruction(uint32_t addr, uint32_t instr); + std::string translate_instruction_body(uint32_t addr, uint32_t instr); + std::string mod_instruction_call(uint32_t addr, uint32_t instr) const; // Register name mapping static std::string reg_name(int reg_num); diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index d243cce2..332bc733 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -852,7 +852,17 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } +std::string CodeGenerator::mod_instruction_call(uint32_t addr, uint32_t instr) const { + const bool selected = std::any_of(config_.mod_instruction_sites.begin(), + config_.mod_instruction_sites.end(), [=](uint32_t site) { + return (site & 0x1FFFFFFFu) == (addr & 0x1FFFFFFFu); + }); + return selected ? fmt::format("psx_mod_instruction(cpu, 0x{:08X}u, 0x{:08X}u);\n", addr, instr) : ""; +} std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { + return mod_instruction_call(addr, instr) + translate_instruction_body(addr, instr); +} +std::string CodeGenerator::translate_instruction_body(uint32_t addr, uint32_t instr) { uint32_t opcode = (instr >> 26) & 0x3F; uint32_t funct = instr & 0x3F; @@ -2078,6 +2088,7 @@ std::string CodeGenerator::translate_basic_block( } else { // Control flow is handled at block exit if (addr == exit_branch_addr) { + ss << config_.indent << mod_instruction_call(addr, instr); std::string delay_saved_cond; // branch condition captured before delay std::string delay_saved_target; // JR/JALR target captured before delay @@ -3371,6 +3382,7 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "#endif\n"; ss << "extern int psx_game_text_native_ok(uint32_t addr); /* stale-static guard (dispatch shard) */\n"; ss << "extern int psx_datashard_enter(CPUState* cpu, uint32_t key); /* data-shard replay/capture (data_shards.c) */\n"; + ss << "extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t);\n"; ss << "extern int psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ss << "extern void psx_datashard_ret(CPUState* cpu); /* data-shard capture finalize */\n"; ss << "extern int psx_vsync_query_hle_enter(CPUState* cpu, uint32_t func, uint32_t counter_addr, uint32_t gpustat_ptr_addr, uint32_t timer1_ptr_addr, uint32_t timer1_cache_addr); /* load_accel.c */\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index c1baf498..45a95d5d 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -62,6 +62,7 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.words("sprite_tag_funcs", c.ws_sprite_tag_funcs); h.words("mod_function_entry_funcs", c.mod_function_entry_funcs); + h.words("mod_instruction_sites", c.mod_instruction_sites); h.words("cull_bias", c.ws_cull_bias_sites); h.words("cull_range", c.ws_cull_range_sites); h.words("cull_a1", c.ws_cull_a1_sites); @@ -1497,6 +1498,14 @@ GameConfig load_game_config(const fs::path& config_path_in) { } // Optional [recompiler] hot_funcs — __attribute__((hot)) on emitted C. // Optional trusted, statically linked game-mod entry hooks. + std::vector mod_instruction_sites; + if (recomp.contains("mod_instruction_sites")) + for (const auto& a : toml::find>(recomp, "mod_instruction_sites")) { + const auto pc = parse_hex(a, "recompiler.mod_instruction_sites"); + if ((pc & 3u) || pc >= 0xC0000000u || (pc & 0x1FFFFFFFu) >= 0x00800000u) + throw std::runtime_error("mod instruction site must be aligned main RAM"); + mod_instruction_sites.push_back(pc); + } std::vector mod_function_entry_funcs; if (recomp.contains("mod_function_entry_funcs")) { const auto& arr = toml::find>( @@ -2208,6 +2217,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_ui_squash*/ ws_auto_ui_squash, /*data_shard_funcs*/ data_shard_funcs, /*mod_function_entry_funcs*/ mod_function_entry_funcs, + /*mod_instruction_sites*/ mod_instruction_sites, /*hot_funcs*/ hot_funcs, /*load_charge_batch*/ load_charge_batch, /*load_charge_batch_funcs*/ load_charge_batch_funcs, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index c9a95d14..b0d305bf 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -868,6 +868,8 @@ struct GameConfig { // entries that dispatch trusted, statically linked mod callbacks. Empty by // default, so projects that do not opt in emit no callback overhead. std::vector mod_function_entry_funcs; + // Opt-in pre-instruction callbacks; plugins supply exact-word guards. + std::vector mod_instruction_sites; // [recompiler] hot_funcs: guest addresses that get __attribute__((hot)) // on their generated C bodies (profile/host locality; no guest semantics). diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index b6bf9d26..6b299e5c 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1792,6 +1792,8 @@ void FullFunctionEmitter::emit_dispatch( out += " * NULL (the default) = pure LLE, dispatch identical to a build without\n"; out += " * the tier. */\n"; out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n"; + out += "extern uint32_t g_psx_mod_guest_functions;\n"; + out += "extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);\n\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += "extern int psx_game_address_in_text(uint32_t addr);\n"; out += "#endif\n\n"; @@ -2168,6 +2170,8 @@ void FullFunctionEmitter::emit_dispatch( out += " cpu->pc = 0;\n"; out += fmt::format(" int lo = 0, hi = {} - 1;\n", total_entries); out += " int found = 0;\n"; + out += " if (g_psx_mod_guest_functions)\n"; + out += " found = psx_mod_dispatch_guest_function(cpu, addr);\n"; out += " /* BIOS HLE tier: consult the hook FIRST, on the pre-normalize\n"; out += " * physical address. It must see (a) the A0/B0/C0 service vectors\n"; out += " * (game thunks jr there with the function number in $t1) and (b)\n"; @@ -2176,7 +2180,7 @@ void FullFunctionEmitter::emit_dispatch( out += " * backends. Handled (rc 1) \xE2\x87\x92 the service completed against guest\n"; out += " * state and the guest resumes at $ra via the trampoline's normal\n"; out += " * return/tail contract below. rc 0 \xE2\x87\x92 pure LLE fall-through. */\n"; - out += " if (g_psx_bios_hle_hook &&\n"; + out += " if (!found && g_psx_bios_hle_hook &&\n"; out += " g_psx_bios_hle_hook(cpu, addr & 0x1FFFFFFFu)) {\n"; out += " cpu->pc = cpu->gpr[31];\n"; out += " found = 1;\n"; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 52685b77..7bc21d64 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -195,6 +195,7 @@ int main(int argc, char** argv) { bool reachable_discovery = false; std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs + std::set mod_instruction_sites; std::set mod_entry_funcs; // trusted game-mod entry hooks std::set hot_funcs; // [recompiler] hot_funcs std::set load_charge_batch_funcs; // [recompiler] load_charge_batch* @@ -252,6 +253,7 @@ int main(int argc, char** argv) { ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end()); mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(), cfg.mod_function_entry_funcs.end()); + mod_instruction_sites.insert(cfg.mod_instruction_sites.begin(), cfg.mod_instruction_sites.end()); hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end()); if (cfg.load_charge_batch) { load_charge_batch_funcs.insert(cfg.load_charge_batch_funcs.begin(), @@ -358,6 +360,7 @@ int main(int argc, char** argv) { ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end()); mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(), wscfg.mod_function_entry_funcs.end()); + mod_instruction_sites.insert(wscfg.mod_instruction_sites.begin(), wscfg.mod_instruction_sites.end()); ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end()); @@ -1226,6 +1229,7 @@ int main(int argc, char** argv) { codegen_config.ws_sprite_tag_funcs = ws_tag_funcs; codegen_config.data_shard_funcs = ds_funcs; codegen_config.mod_function_entry_funcs = mod_entry_funcs; + codegen_config.mod_instruction_sites = mod_instruction_sites; codegen_config.hot_funcs = hot_funcs; codegen_config.load_charge_batch_funcs = load_charge_batch_funcs; codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs; diff --git a/recompiler/tests/full_function_emitter_test.cpp b/recompiler/tests/full_function_emitter_test.cpp index f30f49af..64ea8556 100644 --- a/recompiler/tests/full_function_emitter_test.cpp +++ b/recompiler/tests/full_function_emitter_test.cpp @@ -260,6 +260,19 @@ void patch_range_delay_boundaries(BiosConfig config) { } } +void mod_guest_dispatch_precedes_hardware_routes() { + const auto result = run_case("mod-guest-dispatch", {0x03E00008u, 0u}, + {function_at(kBase, kBase + 4u, {kBase})}); + const auto dispatch = result.dispatch.find("static void psx_dispatch_impl("); + const auto hook = result.dispatch.find("found = psx_mod_dispatch_guest_function(cpu, addr)", dispatch); + const auto hle = result.dispatch.find("if (!found && g_psx_bios_hle_hook", dispatch); + const auto hardware = result.dispatch.find("if (!found && psx_bios_try_native_call_stub", dispatch); + expect(dispatch != std::string::npos && hook > dispatch && hook < hle && hle < hardware, + "mod-defined guest calls are handled before address normalization and hardware dispatch"); + expect(result.dispatch.find("if (g_psx_mod_guest_functions)", dispatch) < hook, + "no active mod guest functions keeps the dispatch path dormant"); +} + int main() { BiosConfig config{}; config.config_path = "test://full-function-emitter"; @@ -282,6 +295,7 @@ int main() { fragment_split_load_falls_back(); noncomplementary_lwl_falls_back(); complementary_lwl_lwr_stays_native(); + mod_guest_dispatch_precedes_hardware_routes(); patch_range_guards(config); patch_range_delay_boundaries(config); diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index cf992ea3..294081a0 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -1202,6 +1202,34 @@ void mod_function_completion_codegen_test() { "unconfigured functions retain the stock entry path"); } +void mod_instruction_codegen_test(const fs::path& root) { + const auto path = write_config(root, "instruction-hooks", + "mod_instruction_sites = [\"0x80010000\", \"0x80010004\"]\n"); + const auto parsed = PSXRecompV4::load_game_config(path); + check(parsed.mod_instruction_sites.size() == 2, "instruction sites parse"); + auto changed = parsed; + changed.mod_instruction_sites.clear(); + check(PSXRecompV4::overlay_codegen_config_hash(parsed) != + PSXRecompV4::overlay_codegen_config_hash(changed), "instruction sites invalidate cached code"); + const auto invalid = write_config(root, "instruction-unaligned", + "mod_instruction_sites = [\"0x80010001\"]\n"); + check_throws([&] { PSXRecompV4::load_game_config(invalid); }, "aligned main RAM", + "instruction sites reject invalid addresses"); + PSXRecomp::CodeGenConfig config{}; + config.mod_instruction_sites = {0x80010000u, 0x80010004u}; + for (bool overlay : {false, true}) { + for (uint32_t word : {0x2402002Au, 0x10400002u}) { + const auto code = generate_first_instruction(word, {}, overlay, config); + check(code.find(fmt::format("psx_mod_instruction(cpu, 0x80010000u, 0x{:08X}u);", word)) != std::string::npos, + "native/overlay emits instruction guard for ALU and branch"); + check(code.find("psx_mod_instruction(cpu, 0x80010004u, 0x00000000u);") != std::string::npos, + "native/overlay includes sequential and branch delay-slot hooks"); + } + } + check(generate_first_instruction(0x2402002Au, {}, false).find("psx_mod_instruction(cpu,") == std::string::npos, + "unconfigured code emits no instruction callbacks"); +} + void cfg_fallthrough_reachability_test() { constexpr uint32_t base = 0x80010000u; PSXRecomp::PS1Executable exe{}; @@ -1252,6 +1280,7 @@ int main() { jump_table_producer_codegen_test(); cfg_codegen_load_delay_test(); mod_function_completion_codegen_test(); + mod_instruction_codegen_test(root); cfg_fallthrough_reachability_test(); } catch (const std::exception& e) { fmt::print(stderr, "FAIL unexpected exception: {}\n", e.what()); diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ec81d585..06b16eb9 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -488,6 +488,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test tests/test_mod_runtime.cpp + src/iso_reader_c.cpp src/mod_runtime.cpp src/mod_packages.cpp src/mod_media.cpp diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 04be60ec..c067c255 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -529,6 +529,8 @@ bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)); * select it by id, and it runs only while a resolved plan activates that id. */ bool mod_register_function_entry_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); +bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, + PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); @@ -539,6 +541,14 @@ struct ModFunctionEntryHook { /* Hooks one implementation registered, in registration order. mod_runtime * flattens these into an address table when the plan's plugins activate. */ std::vector mod_function_entry_hooks(const std::string& id); +std::vector mod_guest_functions(const std::string& id); +struct ModInstructionHook { + uint32_t address, expected; + PSXModFunctionEntryCallback callback; +}; +bool mod_register_instruction_plugin(const std::string& id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback); +std::vector mod_instruction_hooks(const std::string& id); void mod_clear_plugins_for_tests(); /* Every id a trusted implementation registered (activation, vblank or diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 04685b1e..5a1d3409 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -23,6 +23,26 @@ int psx_mod_register_vblank_plugin(const char* id, PSXModVBlankCallback callback); int psx_mod_register_function_entry_plugin( const char* id, uint32_t address, PSXModFunctionEntryCallback callback); +/* Mod-defined guest functions have no original machine-code body. Addresses + * must be aligned and in physical 0x0F000000..0x0FFFFFFF (an unused bus range), + * never hardware/BIOS/game text. Only statically linked code can register one; + * the active package plan owns its availability and resource context. Address + * aliases share one globally unique registration. The callback supplies the + * full function behavior; return publishes $ra through normal dispatch. */ +int psx_mod_register_guest_function_plugin( + const char* id, uint32_t address, PSXModFunctionEntryCallback callback); +int psx_mod_dispatch_guest_function(struct CPUState* cpu, uint32_t address); +extern uint32_t g_psx_mod_guest_functions; +/* Run immediately before a configured instruction, including delay slots. + * The complete instruction word must match both registration and live RAM. + * Callbacks may update registers/data but cannot redirect PC, finish a guest + * function, or re-enter guest execution (pending load/branch state is live). + * Native emits opt in with [recompiler] mod_instruction_sites; dirty-RAM + * execution uses the same guarded table. Inactive plans are inert. */ +int psx_mod_register_instruction_plugin(const char* id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback); +void psx_mod_instruction(struct CPUState* cpu, uint32_t address, uint32_t instruction); +extern uint32_t g_psx_mod_instruction_hooks; /* Called from generated functions listed by the game config and from every * interpreted entry, so the hook contract does not depend on the backend. * Hooks match by code address (segment bits ignored) and run only for plugins @@ -213,6 +233,15 @@ int psx_mod_current_resource_path(const char* resource_id, int psx_mod_current_resource_bytes(const char* resource_id, const uint8_t** bytes, uint64_t* size); +/* Append raw Mode-2 sectors (2336 bytes, starting with the XA subheader) + * from a verified resource owned by this activation callback. Returns their + * native CD LBA in first_lba. Registration is deterministic, append-only and + * rejected outside activation, for invalid ranges or beyond 99:59:74. + * The immutable committed snapshot remains mounted until the plan changes; + * no host path or donor data is saved into guest RAM/savestates. */ +int psx_mod_append_disc_extent(const char* resource_id, uint64_t byte_offset, + uint32_t sector_count, uint32_t* first_lba); + /* Display aspects have no framework ceiling: the native-wide surfaces size * themselves from the live width, and each title caps its own view at what * it has validated (fixed ratio, or the adaptive maximum below). Requests diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 1a448876..4d6672dd 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -53,6 +53,10 @@ void mod_runtime_on_vblank(void); void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, uint8_t* bytes, uint32_t size); void mod_runtime_enable_disc_patches(void); +int mod_runtime_read_disc_extent(uint32_t lba, int raw_sector, + uint8_t* bytes, uint32_t size); +uint32_t mod_runtime_disc_extent_start(void); +uint32_t mod_runtime_disc_sector_count(uint32_t base_count); #ifdef __cplusplus } diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index f610d39e..c381a556 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -113,7 +113,7 @@ * in overlay code (all sign-branch shapes). Appended last; NULL keeps the * native sign. */ /* v26: trusted entry hooks return whether they completed the guest function. */ -#define PSX_OVERLAY_ABI_VERSION 26 +#define PSX_OVERLAY_ABI_VERSION 27 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -356,6 +356,8 @@ typedef struct { int32_t (*ws_cull_scale)(int32_t bound, int32_t half_extent); /* Exact-NCLIP branch sign (ABI v25). */ int32_t (*nclip_exact_sign)(int32_t native_mac0, uint32_t pc); + /* Guarded pre-instruction mod callback (ABI v27). */ + void (*mod_instruction)(CPUState*, uint32_t, uint32_t); } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index c0a58ada..0929c090 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -233,6 +233,10 @@ int32_t psx_ws_player_x_bound(int32_t vanilla) { int32_t psx_ws_screen_x_bound(int32_t vanilla) { return g_cbs.ws_screen_x_bound ? g_cbs.ws_screen_x_bound(vanilla) : vanilla; } +void psx_mod_instruction(CPUState *cpu, uint32_t address, uint32_t word) { + overlay_flush_cycles(); + if (g_cbs.mod_instruction) g_cbs.mod_instruction(cpu, address, word); +} int psx_mod_function_entry(CPUState *cpu, uint32_t address) { overlay_flush_cycles(); return g_cbs.mod_function_entry ? g_cbs.mod_function_entry(cpu, address) : 0; diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 8d723d2c..e39f5b0b 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1460,6 +1460,10 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, } } + extern uint32_t g_psx_mod_instruction_hooks; + extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); + if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn); + /* op 0x20..0x26 = LB/LH/LWL/LW/LBU/LHU/LWR. LWC2 (GTE, 0x32) targets a COP2 * register, not a GPR, so it needs no deferral here. */ const uint32_t ld_op = op_field(insn); diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp index 0dc8e173..e77638ae 100644 --- a/runtime/src/iso_reader_c.cpp +++ b/runtime/src/iso_reader_c.cpp @@ -7,6 +7,7 @@ #include "iso_reader.h" #include "mod_runtime.h" #include +#include extern "C" { @@ -20,18 +21,17 @@ void* iso_open(const char* path) { } int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle) return 0; + if (!handle || !buffer || size < 2048) return 0; auto* reader = static_cast(handle); - (void)size; /* ReadSector always reads 2048 bytes */ - if (!reader->ReadSector(lba, buffer)) return 0; + if (!mod_runtime_read_disc_extent(lba, 0, buffer, size) && !reader->ReadSector(lba, buffer)) return 0; mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); return 1; } int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { - if (!handle || size < 2352) return 0; + if (!handle || !buffer || size < 2352) return 0; auto* reader = static_cast(handle); - if (!reader->ReadRawSector(lba, buffer)) return 0; + if (!mod_runtime_read_disc_extent(lba, 1, buffer, size) && !reader->ReadRawSector(lba, buffer)) return 0; mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); return 1; } @@ -39,6 +39,23 @@ int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { int iso_read_subq(void* handle, uint32_t lba, uint8_t* buffer, int size, int* valid) { if (!handle || !buffer || size < 12 || !valid) return 0; + const auto* reader=static_cast(handle); + const uint32_t start=mod_runtime_disc_extent_start(); + if (start && lba>=start && lbaTrackCount()+1);buffer[2]=1; + const uint32_t rel=lba-start,abs=lba+150; + buffer[3]=bcd(rel/4500);buffer[4]=bcd(rel/75%60);buffer[5]=bcd(rel%75); + buffer[6]=bcd(abs/4500);buffer[7]=bcd(abs/75%60);buffer[8]=bcd(abs%75); + uint16_t crc=0; + for(unsigned i=0;i<10;++i) { + crc^=uint16_t(buffer[i])<<8; + for(unsigned j=0;j<8;++j)crc=uint16_t((crc<<1)^((crc&0x8000)?0x1021:0)); + } + crc=uint16_t(~crc);buffer[10]=uint8_t(crc);buffer[11]=uint8_t(crc>>8); + *valid=1;return 1; + } bool crc_valid = false; if (!static_cast(handle)->ReadSubChannelQ( lba, buffer, &crc_valid)) return 0; @@ -53,22 +70,25 @@ int iso_has_subq_replacements(void* handle) { uint32_t iso_sector_count(void* handle) { if (!handle) return 0; auto* reader = static_cast(handle); - return reader->GetSectorCount(); + return mod_runtime_disc_sector_count(reader->GetSectorCount()); } /* CD-track TOC accessors (multi-track / CD-DA support). track is 1-based. */ int iso_track_count(void* handle) { if (!handle) return 1; - return static_cast(handle)->TrackCount(); + return static_cast(handle)->TrackCount() + (mod_runtime_disc_extent_start()?1:0); } uint32_t iso_track_start_lba(void* handle, int track) { if (!handle) return 0; - return static_cast(handle)->TrackStartLBA(track); + auto* reader=static_cast(handle); + if (track==reader->TrackCount()+1 && mod_runtime_disc_extent_start()) return mod_runtime_disc_extent_start(); + return reader->TrackStartLBA(track); } uint32_t iso_track_pregap_lba(void* handle, int track) { auto* reader = static_cast(handle); + if (reader && track==reader->TrackCount()+1 && mod_runtime_disc_extent_start()) return mod_runtime_disc_extent_start(); return reader ? reader->TrackPregapLBA(track) : 0; } diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index c417135f..45097c3d 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -39,6 +39,8 @@ struct RegisteredPlugin { /* Generated-function entry hooks, keyed by guest address. One id may * observe several functions. */ std::vector> function_entries; + std::vector guest_functions; + std::vector instructions; }; std::map& registered_plugins() { @@ -1475,7 +1477,45 @@ bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && (found->second.activation || found->second.vblank || - !found->second.function_entries.empty()); + !found->second.function_entries.empty() || !found->second.guest_functions.empty() || + !found->second.instructions.empty()); +} + +bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, + PSXModFunctionEntryCallback callback) { + const uint32_t key = address & 0x1FFFFFFFu; + if (!valid_id(id) || !callback || (address & 3u) || + key < 0x0F000000u || key >= 0x10000000u || address >= 0xC0000000u) + return false; + for (const auto& plugin : registered_plugins()) + for (const auto& function : plugin.second.guest_functions) + if (function.address == key) return false; + registered_plugins()[id].guest_functions.push_back({key, callback}); + return true; +} + +std::vector mod_guest_functions(const std::string& id) { + const auto found = registered_plugins().find(id); + return found == registered_plugins().end() + ? std::vector{} : found->second.guest_functions; +} + +bool mod_register_instruction_plugin(const std::string& id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback) { + if (!valid_id(id) || !callback || (address & 3u) || address >= 0xC0000000u || + (address & 0x1FFFFFFFu) >= 0x00800000u) return false; + auto& hooks = registered_plugins()[id].instructions; + const uint32_t key = address & 0x1FFFFFFFu; + for (const auto& hook : hooks) + if (hook.address == key) return false; + hooks.push_back({key, expected, callback}); + return true; +} + +std::vector mod_instruction_hooks(const std::string& id) { + const auto found = registered_plugins().find(id); + return found == registered_plugins().end() + ? std::vector{} : found->second.instructions; } void mod_invoke_activation_plugin(const std::string& id) { @@ -1507,7 +1547,7 @@ std::vector mod_registered_plugin_ids() { std::vector ids; for (const auto& entry : registered_plugins()) if (entry.second.activation || entry.second.vblank || - !entry.second.function_entries.empty()) + !entry.second.function_entries.empty() || !entry.second.guest_functions.empty()) ids.push_back(entry.first); return ids; /* std::map keeps them sorted */ } diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index ea867683..962d2e95 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -54,6 +54,8 @@ extern "C" int fntrace_is_game_started(void); /* Declared in mod_plugins.h; see active_function_entry_hooks() below. */ uint32_t g_psx_mod_function_entry_hooks = 0; +uint32_t g_psx_mod_guest_functions = 0; +uint32_t g_psx_mod_instruction_hooks = 0; /* Guest vblanks seen by the mod runtime: the plugin counters' time base. */ static uint64_t g_mod_vblanks = 0; @@ -61,6 +63,10 @@ namespace PSXRecompV4 { namespace { struct RuntimeMods { + struct DiscExtent { uint32_t lba, count; const uint8_t* data; }; + std::vector disc_extents; + uint32_t extent_start = 0; + bool activating = false; ModPackageManager manager; ModResolution plan; ModResolution validation; @@ -134,6 +140,21 @@ std::vector& active_function_entry_hooks() { return value; } +std::vector& active_guest_functions() { + static std::vector value; + return value; +} + +struct ActiveInstructionHook { + uint32_t key, expected; + PSXModFunctionEntryCallback callback; + const ModResolution::Plugin* plugin; +}; +std::vector& active_instruction_hooks() { + static std::vector value; + return value; +} + inline uint32_t function_entry_key(uint32_t address) { return address & 0x1FFFFFFFu; } @@ -141,6 +162,10 @@ inline uint32_t function_entry_key(uint32_t address) { void clear_function_entry_hooks() { active_function_entry_hooks().clear(); g_psx_mod_function_entry_hooks = 0; + active_guest_functions().clear(); + g_psx_mod_guest_functions = 0; + active_instruction_hooks().clear(); + g_psx_mod_instruction_hooks = 0; } void build_function_entry_hooks(const RuntimeMods& s) { @@ -155,6 +180,20 @@ void build_function_entry_hooks(const RuntimeMods& s) { [](const ActiveFunctionEntryHook& a, const ActiveFunctionEntryHook& b) { return a.key < b.key; }); g_psx_mod_function_entry_hooks = (uint32_t)table.size(); + auto& functions = active_guest_functions(); + for (const auto& plugin : s.plan.plugins) + for (const auto& function : mod_guest_functions(plugin.id)) + functions.push_back({function.address, function.callback, &plugin}); + std::sort(functions.begin(), functions.end(), + [](const auto& a, const auto& b) { return a.key < b.key; }); + g_psx_mod_guest_functions = (uint32_t)functions.size(); + auto& instructions = active_instruction_hooks(); + for (const auto& plugin : s.plan.plugins) + for (const auto& hook : mod_instruction_hooks(plugin.id)) + instructions.push_back({hook.address, hook.expected, hook.callback, &plugin}); + std::stable_sort(instructions.begin(), instructions.end(), + [](const auto& a, const auto& b) { return a.key < b.key; }); + g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); } const ModPackage* selected_package(const std::string& id) { @@ -1183,6 +1222,8 @@ bool mod_runtime_initialize(const std::filesystem::path& root, RuntimeMods& s = state(); s.manager.set_root({}); clear_function_entry_hooks(); + s.disc_extents.clear(); + s.extent_start = 0; s.plan = {}; s.validation = {}; s.raw_disc_index.clear(); @@ -1237,6 +1278,8 @@ bool mod_runtime_clear_for_netplay(std::string* error) { return true; } clear_function_entry_hooks(); + s.disc_extents.clear(); + s.extent_start = 0; s.plan = {}; s.validation = {}; s.raw_disc_index.clear(); @@ -1310,6 +1353,8 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err /* Hooks follow activation, never a bare commit: a new plan runs none of * its function-entry hooks until mod_runtime_activate_plugins(). */ clear_function_entry_hooks(); + s.disc_extents.clear(); + s.extent_start = 0; s.plan = std::move(plan); build_disc_index(s); s.effective_disc_path = std::move(effective_disc); @@ -1402,6 +1447,67 @@ extern "C" void mod_runtime_enable_disc_patches(void) { PSXRecompV4::state().disc_enabled = true; } +extern "C" int psx_mod_append_disc_extent(const char* resource_id, + uint64_t byte_offset, uint32_t sector_count, uint32_t* first_lba) { + using namespace PSXRecompV4; + if (first_lba) *first_lba = 0; + auto& s = state(); + const uint8_t* data = nullptr; + uint64_t size = 0; + if (!s.activating || !first_lba || !sector_count || + !psx_mod_current_resource_bytes(resource_id, &data, &size) || + byte_offset > size || uint64_t(sector_count)*2336 > size-byte_offset) return 0; + try { + uint32_t start = s.extent_start; + if (s.disc_extents.empty()) { + PS1::ISOReader reader; + const auto& mount = s.effective_disc_path.empty() ? s.disc_path : s.effective_disc_path; + if (mount.empty() || !reader.Open(mount.string()) || reader.TrackCount() >= 99) return 0; + start = reader.GetSectorCount(); + if (!start) return 0; + } + const uint32_t lba = s.disc_extents.empty() ? start : + s.disc_extents.back().lba + s.disc_extents.back().count; + if (uint64_t(lba)+sector_count+150 > 450000) return 0; + // Mode-2's duplicated subheader is part of the format contract. + for (uint32_t i=0; i=e.lba && lba-e.lba= 0xC0000000u) return 0; + const auto& functions = active_guest_functions(); + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound(functions.begin(), functions.end(), key, + [](const auto& function, uint32_t k) { return function.key < k; }); + if (it == functions.end() || it->key != key) return 0; + PluginCallbackScope scope(state(), it->plugin, cpu); + it->callback(cpu, address); + cpu->pc = cpu->gpr[31]; + return 1; +} + +extern "C" int psx_mod_register_instruction_plugin(const char* id, uint32_t address, + uint32_t expected, PSXModFunctionEntryCallback callback) { + return id && PSXRecompV4::mod_register_instruction_plugin(id, address, expected, callback); +} + +extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t instruction) { + using namespace PSXRecompV4; + if (!g_psx_mod_instruction_hooks || !cpu || address >= 0xC0000000u) return; + const auto& hooks = active_instruction_hooks(); + const auto key = function_entry_key(address); + auto it = std::lower_bound(hooks.begin(), hooks.end(), key, + [](const auto& h, uint32_t k) { return h.key < k; }); + for (; it != hooks.end() && it->key == key; ++it) { + if (it->expected != instruction || psx_mod_read_word(address) != instruction) continue; + PluginCallbackScope scope(state(), it->plugin, nullptr); + const uint32_t pc = cpu->pc; + it->callback(cpu, address); + if (cpu->pc != pc) std::abort(); + cpu->gpr[0] = 0; + } +} + 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; diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index be9a4419..ec7b0c85 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2474,6 +2474,8 @@ static void init_callbacks(void) { extern int psx_mod_function_entry(CPUState *cpu, uint32_t address); s_callbacks.ws_screen_x_bound = psx_ws_screen_x_bound; s_callbacks.mod_function_entry = psx_mod_function_entry; + extern void psx_mod_instruction(CPUState*, uint32_t, uint32_t); + s_callbacks.mod_instruction = psx_mod_instruction; } /* ABI v14: GTE precision-store tracker — the emitter emits a direct * gte_precision_store_word() call for every swc2 (GTE store-word), diff --git a/runtime/tests/test_interp_mod_entry_guards.py b/runtime/tests/test_interp_mod_entry_guards.py index f8c3c2a6..e2a12204 100644 --- a/runtime/tests/test_interp_mod_entry_guards.py +++ b/runtime/tests/test_interp_mod_entry_guards.py @@ -6,6 +6,11 @@ from pathlib import Path def main(): source = (Path(__file__).resolve().parents[1] / 'src/dirty_ram_interp.c').read_text(encoding='utf-8') + fetched = source[source.index('static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn,\n uint32_t *next_pc_out) {'):] + fetched = fetched[:fetched.index('const uint32_t ld_op')] + assert fetched.count('psx_mod_instruction(cpu, pc, insn)') == 1 + assert fetched.index('if (s_ld_pend_armed') < fetched.index('psx_mod_instruction(cpu, pc, insn)') + assert 'if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn);' in fetched start = source.index('static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr) {') source = source[start:] static_dispatch = source[source.index('/* B-2: statically-compiled'):] diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 4b39481a..e80ab96b 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -123,6 +123,30 @@ static void test_active_entry(CPUState* cpu, uint32_t address) { static void test_disabled_entry(CPUState*, uint32_t) { disabled_entry_hits++; } static void test_unselected_entry(CPUState*, uint32_t) { unselected_entry_hits++; } +static int guest_function_hits; +static int instruction_hits; +static void test_instruction(CPUState* cpu, uint32_t) { + instruction_hits++; + cpu->gpr[5] = 0x80301008u; + if (psx_mod_finish_function(cpu)) failures++; +} +static void test_guest_function(CPUState* cpu, uint32_t) { + guest_function_hits++; + const uint32_t ra = cpu->gpr[31]; + CPUState nested{}; + nested.gpr[31] = 0x80007000u; + const int mode = entry_test_mode; + entry_test_mode = 1; + if (!psx_mod_function_entry(&nested, 0x80003000u) || nested.pc != nested.gpr[31]) failures++; + entry_test_mode = mode; + interrupted_entry_cpu = cpu; + mod_runtime_on_vblank(); + interrupted_entry_cpu = nullptr; + if (!psx_mod_finish_function(cpu)) failures++; + cpu->gpr[2] = cpu->gpr[4] + 7u; + if (cpu->gpr[31] != ra) failures++; +} + static void test_activation_plugin(void) { activation_calls++; } @@ -130,11 +154,29 @@ static void test_activation_plugin(void) { static const uint8_t* media_snapshot; static uint64_t media_size; static int media_access, foreign_media_access; +static uint32_t mounted_lba; +static int extent_ok, invalid_extent, foreign_extent; static void test_media_plugin(void) { media_access = psx_mod_current_resource_bytes("rom", &media_snapshot, &media_size); const uint8_t* foreign = nullptr; uint64_t size = 0; foreign_media_access = psx_mod_current_resource_bytes("private", &foreign, &size); + extent_ok=psx_mod_append_disc_extent("xa",0,2,&mounted_lba); + uint32_t bad=99; + invalid_extent=psx_mod_append_disc_extent("xa",1,2,&bad); + foreign_extent=psx_mod_append_disc_extent("private",0,1,&bad); +} + +extern "C" { +void* iso_open(const char*); +void iso_close(void*); +int iso_read_raw_sector(void*,uint32_t,uint8_t*,int); +int iso_read_sector(void*,uint32_t,uint8_t*,int); +int iso_read_subq(void*,uint32_t,uint8_t*,int,int*); +uint32_t iso_sector_count(void*); +int iso_track_count(void*); +uint32_t iso_track_start_lba(void*,int); +int iso_track_is_audio(void*,int); } static int big_ram_activations; @@ -384,18 +426,52 @@ int main() { "runtime.unselected-entry", 0x80003000u, test_unselected_entry) == 1, "unselected function-entry hook must register"); CPUState entry_cpu{}; + check(psx_mod_register_instruction_plugin("runtime.test-vblank", 0x80003004u, + 0x90A30014u, test_instruction), "register guarded instruction callback"); + check(!psx_mod_register_instruction_plugin("runtime.test-vblank", 0xA0003004u, + 0x90A30014u, test_instruction) && + !psx_mod_register_instruction_plugin("runtime.other", 0x80003005u, + 0x90A30014u, test_instruction), "reject aliased duplicate and unaligned sites"); + psx_mod_write_word(0x80003004u, 0x90A30014u); + check(psx_mod_register_guest_function_plugin( + "runtime.test-vblank", 0x8FFF0000u, test_guest_function), + "trusted callback registers outside the hardware map"); + check(!psx_mod_register_guest_function_plugin( + "runtime.other", 0xAFFF0000u, test_guest_function) && + !psx_mod_register_guest_function_plugin("runtime.other", 0x80003000u, test_guest_function) && + !psx_mod_register_guest_function_plugin("runtime.other", 0x8FFF0001u, test_guest_function) && + !psx_mod_register_guest_function_plugin("runtime.other", 0xCFFF0000u, test_guest_function), + "guest callbacks refuse alias collisions, game addresses, misalignment and KSEG2"); + check(psx_mod_register_guest_function_plugin( + "runtime.unselected-entry", 0x8FFF0004u, test_unselected_entry), + "unselected callback implementation can register"); check(PSXRecompV4::mod_runtime_initialize( root, "SLUS-RUNTIME", 0x80002000, {}, &error), error.c_str()); check(PSXRecompV4::mod_runtime_commit(cue_path, &error), "CUE and its data-track BIN must have the same mod target identity"); /* Committed but not yet activated: no hook may run. */ + psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30014u); + check(instruction_hits == 0 && !g_psx_mod_instruction_hooks, "instruction hooks await activation"); + check(!psx_mod_dispatch_guest_function(&entry_cpu, 0x8FFF0000u) && + g_psx_mod_guest_functions == 0, + "committed guest functions remain unavailable until activation"); psx_mod_function_entry(&entry_cpu, 0x80003000u); check(g_psx_mod_function_entry_hooks == 0 && active_entry_hits == 0, "function-entry hooks must not run before plugin activation"); mod_runtime_activate_plugins(); check(activation_calls == 1, "resolved trusted plugin must activate before runtime startup"); + entry_cpu.pc = 0x80004000u; + psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30014u); + psx_mod_instruction(&entry_cpu, 0xA0003004u, 0x90A30014u); + check(instruction_hits == 2 && entry_cpu.gpr[5] == 0x80301008u && + entry_cpu.pc == 0x80004000u, "instruction aliases preserve control flow and edit registers"); + psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30015u); + psx_mod_write_word(0x80003004u, 0x90A30015u); + psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30014u); + check(instruction_hits == 2, "fetched and live instruction guards reject changed code"); + psx_mod_write_word(0x80003004u, 0x90A30014u); mod_runtime_on_vblank(); check(plugin_calls == 1, "resolved trusted plugin must run on guest VBlank"); @@ -416,6 +492,11 @@ int main() { "hooks of plugins the plan does not activate must never run"); /* A replaced plan drops every hook until its own activation. */ check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), error.c_str()); + psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30014u); + check(instruction_hits == 2 && !g_psx_mod_instruction_hooks, "clearing removes instruction hooks"); + check(!psx_mod_dispatch_guest_function(&entry_cpu, 0x8FFF0000u) && + g_psx_mod_guest_functions == 0, + "clearing the plan drops guest callback availability"); psx_mod_function_entry(&entry_cpu, 0x80003000u); check(g_psx_mod_function_entry_hooks == 0 && active_entry_hits == 3, "clearing the plan must drop its function-entry hooks"); @@ -450,6 +531,24 @@ int main() { "entry completion survives VBlank callbacks during a native call"); entry_test_mode = 0; + entry_cpu.gpr[4] = 35u; + const uint32_t guest_sp = entry_cpu.gpr[29]; + check(g_psx_mod_guest_functions == 1 && + psx_mod_dispatch_guest_function(&entry_cpu, 0x8FFF0000u) && + entry_cpu.gpr[2] == 42u && entry_cpu.pc == 0x80005000u && + entry_cpu.gpr[29] == guest_sp, + "active callback returns its result through normal guest PC/stack state"); + check(psx_mod_dispatch_guest_function(&entry_cpu, 0x0FFF0000u) && + psx_mod_dispatch_guest_function(&entry_cpu, 0xAFFF0000u) && + guest_function_hits == 3, + "guest callback aliases retain nested entry and VBlank scope"); + check(!psx_mod_dispatch_guest_function(&entry_cpu, 0x8FFF0004u) && + !psx_mod_dispatch_guest_function(&entry_cpu, 0x8FFF0001u) && + !psx_mod_dispatch_guest_function(&entry_cpu, 0xCFFF0000u) && + !psx_mod_dispatch_guest_function(nullptr, 0x8FFF0000u) && + unselected_entry_hits == 0, + "unselected, unknown, misaligned and invalid callback requests fall through"); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; ram[0x1100] = 0; ram[0x1101] = 0; ram[0x1102] = 1; ram[0x1103] = 0; @@ -784,6 +883,10 @@ int main() { rom[32] = 0x5a; const auto rom_path = media_root / "owner.z64"; write_bytes(rom_path, rom); + std::vector xa(2336*2); + xa[2]=xa[6]=0x64;xa[8]=0x52; + xa[2336+2]=xa[2336+6]=0xE4;xa[2336+8]=0x63; + const auto xa_path=media_root/"owner.xa";write_bytes(xa_path,xa); write_text(media_root / "packages/media.test/1.0.0/manifest.toml", "format_version = 8\nid = \"media.test\"\nversion = \"1.0.0\"\nname = \"Media Test\"\n" "[[target]]\ngame_id = \"READER\"\n" @@ -791,15 +894,17 @@ int main() { "[[feature]]\nid = \"private\"\nname = \"Private\"\n" "[[resource]]\nfeature = \"active\"\nid = \"rom\"\nlabel = \"ROM\"\n" "format = \"n64-rom\"\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n" + "[[resource]]\nfeature = \"active\"\nid = \"xa\"\nlabel = \"XA\"\n" + "format = \"file\"\nrequired = true\nsize = 4672\nsha256 = \"" + sha256_hex(xa) + "\"\n" "[[resource]]\nfeature = \"private\"\nid = \"private\"\nlabel = \"Private\"\n" "format = \"n64-rom\"\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n" "[[plugin]]\nfeature = \"active\"\nid = \"media.test.plugin\"\n"); write_text(media_root / "state.toml", "format_version = 2\n[[feature]]\npackage_id = \"media.test\"\nid = \"active\"\nenabled = true\n" - "[feature.resources]\nrom = \"" + rom_path.generic_string() + "\"\n"); + "[feature.resources]\nrom = \"" + rom_path.generic_string() + "\"\nxa = \"" + xa_path.generic_string() + "\"\n"); check(psx_mod_register_activation_plugin("media.test.plugin", test_media_plugin), "register media consumer"); check(PSXRecompV4::mod_runtime_initialize(media_root, "READER", 0, {}, &error), "media initialize"); - check(PSXRecompV4::mod_runtime_commit({}, &error), "verify and commit owner media"); + check(PSXRecompV4::mod_runtime_commit(iso_path, &error), "verify and commit owner media"); const uint8_t* unavailable = nullptr; uint64_t unavailable_size = 0; check(!psx_mod_current_resource_bytes("rom", &unavailable, &unavailable_size), "bytes unavailable outside plugin callback"); @@ -807,11 +912,34 @@ int main() { check(media_access && media_size == rom.size() && std::equal(rom.begin(), rom.end(), media_snapshot), "plugin receives exact verified bytes"); check(!foreign_media_access, "inactive feature cannot supply bytes to another feature"); + check(extent_ok && mounted_lba==24 && !invalid_extent && !foreign_extent,"activation mounts only owned bounded resource extents"); + uint32_t bad_lba=1; + check(!psx_mod_append_disc_extent("xa",0,1,&bad_lba)&&!bad_lba,"late mounting refused"); + void* mounted=iso_open(iso_path.string().c_str()); + check(mounted&&iso_sector_count(mounted)==24,"BIOS sees original disc before activation is enabled"); + mod_runtime_enable_disc_patches(); + std::array subq{};int valid=0; + check(iso_sector_count(mounted)==26&&iso_track_count(mounted)==2&& + iso_track_start_lba(mounted,2)==24&&!iso_track_is_audio(mounted,2),"appended extents expose one data track and leadout"); + check(iso_read_raw_sector(mounted,24,sector.data(),sector.size())&§or[12]==0&§or[13]==2&§or[14]==0x24&§or[15]==2&& + std::equal(xa.begin(),xa.begin()+2336,sector.begin()+16),"native raw sector header and exact XA subheader/audio bytes"); + check(iso_read_sector(mounted,25,sector.data(),sector.size())&§or[0]==0x63,"cooked access resolves the same extent"); + check(!iso_read_sector(mounted,26,sector.data(),sector.size())&& + !iso_read_raw_sector(mounted,24,sector.data(),2351),"extent end and short raw buffers rejected"); + check(iso_read_subq(mounted,24,subq.data(),subq.size(),&valid)&&valid&&subq[0]==0x41&&subq[1]==2&&subq[5]==0,"appended data track has native subchannel position"); + check(iso_read_sector(mounted,22,sector.data(),sector.size())&§or[1]==7,"base sectors unchanged"); + xa[8]=0xff;write_bytes(xa_path,xa); + check(iso_read_raw_sector(mounted,24,sector.data(),sector.size())&§or[24]==0x52,"streaming uses verified snapshot after external file changes"); + mod_runtime_activate_plugins(); + check(mounted_lba==24&&iso_sector_count(mounted)==26,"reactivation has stable LBAs and no duplicate extents"); rom[32] ^= 1; write_bytes(rom_path, rom); check(media_snapshot && media_snapshot[32] == 0x5a, "committed bytes survive owner file changes"); check(!PSXRecompV4::mod_runtime_commit({}, &error), "new commit rejects modified media"); check(PSXRecompV4::mod_runtime_clear_for_netplay(&error), "netplay clears donor plan"); + check(iso_sector_count(mounted)==24&&iso_track_count(mounted)==1&& + !iso_read_raw_sector(mounted,24,sector.data(),sector.size()),"clearing plan removes all donor sectors and restores TOC"); + iso_close(mounted); fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";