From 13b7f81d29d056460a69634dc79f7f2990ba1151 Mon Sep 17 00:00:00 2001 From: Matt Stanley <1379tech@gmail.com> Date: Mon, 19 Oct 2026 16:37:32 -0700 Subject: [PATCH] Add guarded instruction variants fixed during offline mod activation Keep selector zero stock and seal all choices until the next session restart. Refuse nonzero online activation, scope non-stock save states by frozen choices, and preserve original instruction guards when combined with effective-address adapters. Use overlay ABI34 on current master without the old menu dependency stack. --- docs/MOD_PACKAGES.md | 22 ++ docs/config_schema.md | 47 +++ overlay_toolchain/include/overlay_api.h | 7 +- .../include/overlay_dispatch_preamble.c.inc | 3 + recompiler/CMakeLists.txt | 41 ++ recompiler/include/code_generator.h | 11 + recompiler/src/code_generator.cpp | 59 ++- recompiler/src/config_loader.cpp | 72 ++++ recompiler/src/config_loader.h | 23 ++ recompiler/src/main_psx.cpp | 12 + .../tests/mod_immediate_codegen_test.cpp | 357 ++++++++++++++++++ runtime/CMakeLists.txt | 3 + runtime/include/mod_immediate.h | 74 ++++ runtime/include/mod_plugins.h | 15 + runtime/include/overlay_api.h | 7 +- .../include/overlay_dispatch_preamble.c.inc | 3 + runtime/runtime.cmake | 1 + runtime/src/dirty_ram_interp.c | 4 + runtime/src/main.cpp | 39 +- runtime/src/mod_immediate.c | 107 ++++++ runtime/src/mod_runtime.cpp | 28 +- runtime/src/overlay_loader.c | 2 + runtime/tests/test_draw_distance_interp.c | 17 +- .../tests/test_interp_mod_immediate_guards.py | 42 +++ runtime/tests/test_mod_immediate.c | 188 +++++++++ runtime/tests/test_mod_runtime.cpp | 38 ++ runtime/tests/test_ws_cull_edge_interp.c | 1 + 27 files changed, 1200 insertions(+), 23 deletions(-) create mode 100644 recompiler/tests/mod_immediate_codegen_test.cpp create mode 100644 runtime/include/mod_immediate.h create mode 100644 runtime/src/mod_immediate.c create mode 100644 runtime/tests/test_interp_mod_immediate_guards.py create mode 100644 runtime/tests/test_mod_immediate.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index cc6acac3..60431ea6 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1054,6 +1054,28 @@ re-enter guest execution, because pending branch/load state is live. do not change guest behavior. Function entry replacement remains the separate `psx_mod_register_function_entry_plugin` interface. +### Guarded immediate selectors + +A plugin that must change a constant, offset or address inside compiled game +code (a capacity compare, a relocated table) lists the instruction under +`[mod_immediate]` in `game.toml` and picks the variant with +`psx_mod_set_immediate_selector(group, selector)` from its activation +callback. The instruction bytes stay original, so static AOT code, cached +overlays and the interpreter keep matching their guards and run the same +variant. Selection is accepted only during offline activation, then sealed until +restart; later attempts, including switching back to selector 0, are refused. +Every new session starts at selector 0 (the stock instruction). Nonzero online +activation is refused and invalidates the launch even if a plugin ignores the +setter result. This capability does not negotiate online variants. + +Non-stock frozen selections join the existing save-state content scope, including +for packages declaring shared saves. A state made with a different selection is +outside the session's slot namespace; the chosen values do not rewind or switch +mid-session. Memory-card paths/formats are unchanged by this selector policy. Existing +unscoped states are not automatically assigned a non-stock selection: they do +not record which variant created them. Config rules and the emitted switch: docs/config_schema.md "Mod +immediates". + ### Presentation bezel packages The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL diff --git a/docs/config_schema.md b/docs/config_schema.md index 1dd45fa6..f6ffd56e 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -447,6 +447,49 @@ max = 0x1BF - Regenerate after changing the list. With no sites the generated code is unchanged. +## Mod immediates (`[mod_immediate]`) + +A trusted mod sometimes needs the same compiled instruction to behave +differently from one session to the next without changing the guest bytes +(a capacity compared against `slti v0, a0, 32`, a table address formed by +`addiu a1, a1, 0x6988`, a struct offset in `lbu v0, 0x2E9(v1)`). Patching RAM +fails for statically compiled code: every function carries a CRC of its +original bytes and a patched function drops to the interpreter. Listing the +instruction as a mod immediate site keeps the bytes and the CRC, and moves the +choice into the generated code: + +```toml +[mod_immediate] +sites = [ + { address = "0x80023500", expected = "0x28820020", group = 1, values = [0x40, 0x80] }, # slti v0, a0, 0x20 + { address = "0x80023B14", expected = "0x24A5AEB1", group = 1, values = [0xAEB1, 0x9000] }, +] +``` + +- `expected` is the complete word at `address` and must be an I-type ALU + instruction (`ADDI`..`LUI`, opcodes 0x08-0x0F) or a byte/half/word load or + store (`LB`..`SW`, 0x20-0x2B). Branches, jumps and R-type instructions are + refused. `group` is 0..63; `values` lists up to 15 replacement 16-bit + immediates (decimal, hex, or negative), selector 1 first. +- Selector 0 is the original instruction. A trusted plugin selects variant N of + every site in a group with `psx_mod_set_immediate_selector(group, N)` from its + activation callback; the selector is 0 again at every session start, so an + inactive mod, a disabled feature and netplay all run the stock code. +- Generated code emits `switch (psx_mod_immediate_select(group))` with one case + per value, each translated exactly like the instruction it stands for + (cycle accounting, PGXP hooks, load-delay handling), plus the original as the + default. The dirty-RAM interpreter substitutes the same word + (`psx_mod_immediate_patch`), so native and interpreted execution agree. The + forwarder is part of the overlay ABI (v31). +- A site is keyed by physical address and word. Overlays share load addresses, + so one address may carry several sites with different words; a variant whose + word differs is left alone in overlay mode and is an error in a main EXE. +- The sites enter the overlay-cache identity (the hash changes only when the list + is non-empty). Regenerate after changing the list; with no sites the generated + code is unchanged. +- The mod owns the consequences: a relocated table needs its own free memory and + a save written under one selector is not interchangeable with another's. + ## Runtime block Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually) @@ -1226,3 +1269,7 @@ ranges. A BIOS with no copies (runs entirely from ROM) is valid: normalization degenerates to the KSEG mask. Semantic invariants (disjoint windows, no fold-output/input intersection, single bless window) are enforced at load; violations refuse to build. + +Immediate selector choices are fixed during offline mod activation until the +next session restart. Non-stock choices isolate savestate slots through the +existing content scope; live setter calls and nonzero online selection fail. diff --git a/overlay_toolchain/include/overlay_api.h b/overlay_toolchain/include/overlay_api.h index 9f37b58e..36c36680 100644 --- a/overlay_toolchain/include/overlay_api.h +++ b/overlay_toolchain/include/overlay_api.h @@ -126,8 +126,9 @@ * already holds every release-line callback; shards built against either * v28 or v29 are rejected and recompiled. * v31/v32: reserved for the experimental selector/adapter stack. - * v33: guarded load/store effective-address adapter on the master layout. */ -#define PSX_OVERLAY_ABI_VERSION 33 + * v33: guarded load/store effective-address adapter on the master layout. + * v34: frozen, activation-only immediate selectors on the v33 layout. */ +#define PSX_OVERLAY_ABI_VERSION 34 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -391,6 +392,8 @@ typedef struct OverlayCallbacks { void (*mod_instruction)(CPUState*, uint32_t, uint32_t); /* Guarded load/store effective-address adapters (ABI v33). */ uint32_t (*mod_memory_address)(const CPUState*, uint32_t, uint32_t, uint32_t); + /* [[mod_immediate.sites]] selector of a group (ABI v34); NULL = stock. */ + uint32_t (*mod_immediate_select)(uint32_t group); } OverlayCallbacks; #ifdef __cplusplus diff --git a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc index 05636328..5a62d5a7 100644 --- a/overlay_toolchain/include/overlay_dispatch_preamble.c.inc +++ b/overlay_toolchain/include/overlay_dispatch_preamble.c.inc @@ -270,6 +270,9 @@ uint32_t psx_mod_memory_address(const CPUState *cpu, uint32_t pc, uint32_t word, overlay_flush_cycles(); return g_cbs.mod_memory_address ? g_cbs.mod_memory_address(cpu, pc, word, address) : address; } +uint32_t psx_mod_immediate_select(uint32_t group) { + return g_cbs.mod_immediate_select ? g_cbs.mod_immediate_select(group) : 0u; +} 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/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index b06c8a0b..a8c31fe0 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -596,6 +596,35 @@ if(BUILD_TESTING) add_test(NAME draw_distance_codegen_test COMMAND draw_distance_codegen_test) + # [[mod_immediate.sites]]: config rules, the per-selector switch emitted + # for loads, stores, ALU immediates and delay slots, the guards and the + # overlay-cache identity. + add_executable(mod_immediate_codegen_test + tests/mod_immediate_codegen_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(mod_immediate_codegen_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(mod_immediate_codegen_test PRIVATE fmt rabbitizer) + add_test(NAME mod_immediate_codegen_test + COMMAND mod_immediate_codegen_test) + # The same explicit cull kinds in the dirty-RAM interpreter: gpu.c's # sorted, capped site store and real interpreted bgez/bltz/branch-keep/ # clip-edge instructions at margin 0 and at a forced margin. The test @@ -652,6 +681,18 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_BINARY_DIR}/draw_distance_interp_stderr.txt) endif() + # [[mod_immediate.sites]] runtime store, selectors and word substitution + # (mod_immediate.c). The interpreter call site is pinned by + # runtime/tests/test_interp_mod_immediate_guards.py. + add_executable(mod_immediate_unit_test + ../runtime/tests/test_mod_immediate.c) + target_include_directories(mod_immediate_unit_test PRIVATE + ../runtime/include + ../runtime/src) + add_test(NAME mod_immediate_unit_test + COMMAND mod_immediate_unit_test + ${CMAKE_CURRENT_BINARY_DIR}/mod_immediate_unit_stderr.txt) + # Uncached (KSEG1) code charges a fetch before every instruction; cached # code keeps the line-leader rule (test_uncached_fetch_charge.py checks # the BIOS emitter on OpenBIOS and the cycle model). diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index b58438a7..7a824f34 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -67,6 +67,9 @@ struct CodeGenConfig { // Only explicitly listed guest functions call the runtime dispatcher. std::set mod_function_entry_funcs; std::set mod_instruction_sites; + // [[mod_immediate.sites]]: guarded instructions whose immediate a trusted mod + // selects at run time; emitted as a selector switch over every variant. + std::vector mod_immediate_sites; // [recompiler] hot_funcs: emit __attribute__((hot)) on these guest // addresses (MotK VLC leaves, etc.). Host locality hint only. @@ -495,6 +498,14 @@ private: // The [[draw_distance.clamp]] statement for (addr, instr), or "". std::string draw_distance_clamp_prefix(uint32_t addr, uint32_t instr) const; std::string mod_instruction_call(uint32_t addr, uint32_t instr) const; + // The [[mod_immediate.sites]] entry for (addr, instr), or nullptr. In a main + // EXE a word mismatch at a listed address is a hard error; overlay code may + // hold unrelated words at a shared load address and is left unchanged. + const PSXRecompV4::ModImmediateSite* find_mod_immediate_site(uint32_t addr, uint32_t instr) const; + // The instruction translated once per selector: `switch (psx_mod_immediate_select(group))` + // with case N running the word whose imm16 is values[N-1] and default the original. + std::string mod_immediate_translation(uint32_t addr, uint32_t instr, + const PSXRecompV4::ModImmediateSite& site); // 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 705e2643..19833abc 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -921,6 +921,42 @@ std::string CodeGenerator::mod_instruction_call(uint32_t addr, uint32_t instr) c }); return selected ? fmt::format("psx_mod_instruction(cpu, 0x{:08X}u, 0x{:08X}u);\n", addr, instr) : ""; } +const PSXRecompV4::ModImmediateSite* CodeGenerator::find_mod_immediate_site( + uint32_t addr, uint32_t instr) const { + const uint32_t phys = addr & 0x1FFFFFFFu; + bool address_listed = false; + for (const auto& site : config_.mod_immediate_sites) { + if ((site.address & 0x1FFFFFFFu) != phys) continue; + address_listed = true; + if (site.expected == instr) return &site; + } + if (address_listed && !config_.overlay_mode) { + fmt::print(stderr, + "ERROR: [[mod_immediate.sites]] expected word does not match " + "0x{:08X} at 0x{:08X}\n", instr, addr); + std::exit(1); + } + return nullptr; +} + +std::string CodeGenerator::mod_immediate_translation( + uint32_t addr, uint32_t instr, const PSXRecompV4::ModImmediateSite& site) { + const std::string& ind = config_.indent; + std::string out = ind + fmt::format( + "switch (psx_mod_immediate_select({}u)) {{ /* mod immediate 0x{:08X} */\n", + site.group, addr); + for (size_t i = 0; i < site.values.size(); ++i) { + const uint32_t word = (instr & 0xFFFF0000u) | site.values[i]; + out += ind + fmt::format("case {}u: {{ /* 0x{:08X} */\n", i + 1u, word); + out += translate_instruction_core(addr, word, "", instr) + "\n"; + out += ind + " break;\n" + ind + "}\n"; + } + out += ind + "default: {\n"; + out += translate_instruction_core(addr, instr) + "\n"; + out += ind + " break;\n" + ind + "}\n" + ind + "}"; + return out; +} + // [[draw_distance.clamp]]: a main-EXE ordering-table range guard. While a // mod has the clamps on, the site's register is clamped to `max` before the // instruction runs, so the guard keeps a far primitive in the last OT slot @@ -962,9 +998,12 @@ std::string CodeGenerator::draw_distance_clamp_prefix(uint32_t addr, std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { std::string clamp = draw_distance_clamp_prefix(addr, instr); const std::string hook = mod_instruction_call(addr, instr); - if (clamp.empty()) return hook + translate_instruction_core(addr, instr); + const PSXRecompV4::ModImmediateSite* imm_site = find_mod_immediate_site(addr, instr); + const std::string body = imm_site ? mod_immediate_translation(addr, instr, *imm_site) + : translate_instruction_core(addr, instr); + if (clamp.empty()) return hook + body; // The core translation carries its own indent; give the clamp the same. - return hook + config_.indent + clamp + "\n" + translate_instruction_core(addr, instr); + return hook + config_.indent + clamp + "\n" + body; } std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t instr, @@ -2256,6 +2295,10 @@ std::string CodeGenerator::translate_basic_block( if (defer_load) { const std::string lhs = fmt::format("cpu->gpr[{}] =", load_dest); const size_t pos = emitted.find(lhs); + // A [[mod_immediate.sites]] load carries one translation per + // selector; every copy must write the deferred temporary. + const bool per_selector = + emitted.find("psx_mod_immediate_select(") != std::string::npos; if (pos != std::string::npos) { const std::string temp = fmt::format("psx_ldd_{:08X}", addr); // Declare the temp in the pair block, not inline at the @@ -2272,6 +2315,16 @@ std::string CodeGenerator::translate_basic_block( if (hook != std::string::npos) emitted.replace(hook, pgxp_tail.size(), fmt::format(", _pgxa, {});", temp)); + if (per_selector) { + // The remaining selector copies, same two rewrites. + for (size_t at = emitted.find(lhs); at != std::string::npos; + at = emitted.find(lhs, at + temp.size())) + emitted.replace(at, lhs.size(), temp + " ="); + for (size_t at = emitted.find(pgxp_tail); at != std::string::npos; + at = emitted.find(pgxp_tail, at + 1)) + emitted.replace(at, pgxp_tail.size(), + fmt::format(", _pgxa, {});", temp)); + } ss << config_.indent << "{ /* MIPS-I load-delay pair */\n"; ss << config_.indent << fmt::format(" uint32_t {} = 0;\n", temp); @@ -3622,6 +3675,8 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { 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 uint32_t psx_mod_memory_address(const CPUState*, uint32_t, uint32_t, uint32_t);\n"; + if (!config_.mod_immediate_sites.empty()) + ss << "extern uint32_t psx_mod_immediate_select(uint32_t group); /* [[mod_immediate.sites]] selector (mod_immediate.c) */\n"; ss << "extern int psx_mod_function_entry(CPUState* cpu, uint32_t address); /* trusted opt-in game-mod hook */\n"; ss << "extern int psx_ws_masked_reject(uint32_t flags, uint32_t mask); /* guarded packed-coordinate reject */\n"; ss << "extern int psx_ws_nclip_branch(uint32_t pc, uint32_t instr, int32_t mac0, int vanilla);\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 6ca7b5c8..a8cb2764 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -81,6 +81,23 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { // does not use the feature keeps the hash (and every overlay cache) it had. if (!c.mod_instruction_sites.empty()) h.words("mod_instruction_sites", c.mod_instruction_sites); + if (!c.mod_immediate_sites.empty()) { + // Appended only when used (same rule as above). Order-independent. + auto sites = c.mod_immediate_sites; + std::sort(sites.begin(), sites.end(), [](const auto& x, const auto& y) { + const uint32_t ax = x.address & 0x1FFFFFFFu, ay = y.address & 0x1FFFFFFFu; + return ax != ay ? ax < ay : x.expected < y.expected; + }); + h.tag("mod_immediate_sites"); + h.u32((uint32_t)sites.size()); + for (const auto& site : sites) { + h.u32(site.address & 0x1FFFFFFFu); + h.u32(site.expected); + h.u32(site.group); + h.u32((uint32_t)site.values.size()); + for (const uint16_t v : site.values) h.u32(v); + } + } h.words("cull_bias", c.ws_cull_bias_sites); if (!c.ws_cull_bias_lower_sites.empty()) h.words("cull_bias_lower", c.ws_cull_bias_lower_sites); @@ -1174,6 +1191,60 @@ bool draw_distance_clamp_reads(uint32_t instr, uint32_t reg) { return false; } +std::string mod_immediate_site_error(const ModImmediateSite& site) { + if ((site.address & 3u) != 0u) return "address is not instruction-aligned"; + if ((site.address & 0x1FFFFFFFu) >= 0x00800000u || site.address >= 0xC0000000u) + return "address is not main RAM"; + const uint32_t op = site.expected >> 26; + const bool alu_imm = op >= 0x08u && op <= 0x0Fu; + const bool load_store = op >= 0x20u && op <= 0x2Bu && op != 0x27u; + if (!alu_imm && !load_store) + return "expected is not an I-type ALU/load/store instruction " + "(ADDI..LUI or LB..SW)"; + if (site.group >= 64u) return "group must be 0..63"; + if (site.values.empty()) return "values must list at least one immediate"; + if (site.values.size() > 15u) return "at most 15 values"; + return {}; +} + +// [[mod_immediate.sites]] (docs/config_schema.md "Mod immediates"). +static std::vector parse_mod_immediate_sites( + const toml::value& cfg, const fs::path& config_path) { + std::vector sites; + if (!cfg.contains("mod_immediate")) return sites; + const toml::value& mi = toml::find(cfg, "mod_immediate"); + if (!mi.contains("sites")) return sites; + std::set> seen; + for (const auto& item : toml::find(mi, "sites")) { + ModImmediateSite site; + site.address = parse_hex(toml::find(item, "address"), + "mod_immediate.sites.address"); + site.expected = parse_hex(toml::find(item, "expected"), + "mod_immediate.sites.expected"); + site.group = (uint32_t)toml::find(item, "group"); + for (const auto& v : toml::find(item, "values")) { + const int64_t n = v.as_integer(); + if (n < -32768 || n > 65535) + throw std::runtime_error(fmt::format( + "{}: mod_immediate site 0x{:08X}: value {} does not fit " + "16 bits", config_path.string(), site.address, n)); + site.values.push_back((uint16_t)n); + } + if (const std::string why = mod_immediate_site_error(site); !why.empty()) + throw std::runtime_error(fmt::format( + "{}: mod_immediate site 0x{:08X}: {}", config_path.string(), + site.address, why)); + // Overlays share load addresses, so one address may carry several + // sites; only the (address, word) pair must be unique. + if (!seen.insert({site.address & 0x1FFFFFFFu, site.expected}).second) + throw std::runtime_error(fmt::format( + "{}: duplicate mod_immediate site 0x{:08X}", + config_path.string(), site.address)); + sites.push_back(std::move(site)); + } + return sites; +} + // [[draw_distance.clamp]] (docs/config_schema.md "Draw-distance clamps"). static std::vector parse_draw_distance_clamps( const toml::value& cfg, const fs::path& config_path) { @@ -2876,6 +2947,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { loaded.draw_distance_clamp_sites = parse_draw_distance_clamps(cfg, config_path); loaded.quality_presets = parse_quality_presets(cfg, root, config_path); + loaded.mod_immediate_sites = parse_mod_immediate_sites(cfg, config_path); return loaded; } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 20ff8b08..3766dffd 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -132,6 +132,27 @@ struct DrawDistanceClampSite { int32_t max = 0; }; +// [[mod_immediate.sites]]: a guarded I-type instruction whose 16-bit immediate a +// trusted mod may swap at run time. The complete word at `address` must equal +// `expected` (an unrelated overlay word at the same address is left alone). +// Selector 0 always runs `expected` unchanged; selector N (1-based) runs the +// same instruction with `values[N-1]` in the immediate field. The selector +// belongs to `group` (0..63) and is set by the mod +// (psx_mod_set_immediate_selector). Because the instruction bytes in RAM never +// change, static / cached native code keeps matching its original-bytes guard; +// generated code, the interpreter and every overlay path pick the immediate +// from the same selector. Only ADDI..LUI (0x08-0x0F) and the byte/half/word +// loads and stores (0x20-0x2B) qualify. +struct ModImmediateSite { + uint32_t address = 0; + uint32_t expected = 0; + uint32_t group = 0; + std::vector values; // imm16 per selector 1..N +}; + +// Empty on success; otherwise why `site` cannot be a mod-immediate site. +std::string mod_immediate_site_error(const ModImmediateSite& site); + // True when `instr` is an ALU instruction (I-type or SPECIAL R-type, not a // load, store, branch or jump) that reads GPR `reg`. These are the only // instructions a draw-distance clamp may precede. @@ -1502,6 +1523,8 @@ struct GameConfig { // [quality.low/medium/high/ultra] -- graphics presets, lowest first. Empty: // the title has no presets and [video] alone applies (unchanged). std::vector quality_presets; + // [[mod_immediate.sites]]: run-time-selectable immediates (ModImmediateSite). + std::vector mod_immediate_sites; }; // Effective clip_edge_width: explicit value, else screen_w_imms[0], else 320. diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index fe51133b..f7b87e7c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -220,6 +220,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_tag_funcs; // [widescreen] sprite_tag_funcs std::set ds_funcs; // [data_shards] funcs std::set mod_instruction_sites; + std::vector mod_immediate_sites; // [[mod_immediate.sites]] std::set mod_entry_funcs; // trusted game-mod entry hooks std::vector ws_cull_masked_reject; std::set hot_funcs; // [recompiler] hot_funcs @@ -289,6 +290,7 @@ static int psxrecomp_game_main(int argc, char** argv) { 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()); + mod_immediate_sites = cfg.mod_immediate_sites; 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(), @@ -407,6 +409,15 @@ static int psxrecomp_game_main(int argc, char** argv) { 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()); + // Same TOML as --config adds nothing; a distinct one adds its own sites. + for (const auto& site : wscfg.mod_immediate_sites) { + const bool known = std::any_of(mod_immediate_sites.begin(), mod_immediate_sites.end(), + [&](const PSXRecompV4::ModImmediateSite& have) { + return (have.address & 0x1FFFFFFFu) == (site.address & 0x1FFFFFFFu) && + have.expected == site.expected; + }); + if (!known) mod_immediate_sites.push_back(site); + } ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end()); ws_cull_bias_lower.insert(wscfg.ws_cull_bias_lower_sites.begin(), wscfg.ws_cull_bias_lower_sites.end()); ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end()); @@ -1508,6 +1519,7 @@ static int psxrecomp_game_main(int argc, char** argv) { codegen_config.mod_function_entry_funcs = mod_entry_funcs; codegen_config.ws_cull_masked_reject_sites = ws_cull_masked_reject; codegen_config.mod_instruction_sites = mod_instruction_sites; + codegen_config.mod_immediate_sites = mod_immediate_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/mod_immediate_codegen_test.cpp b/recompiler/tests/mod_immediate_codegen_test.cpp new file mode 100644 index 00000000..10b25312 --- /dev/null +++ b/recompiler/tests/mod_immediate_codegen_test.cpp @@ -0,0 +1,357 @@ +// [[mod_immediate.sites]]: config parsing and validation, the per-selector +// switch emitted for a listed instruction (ALU immediate, load, store, branch +// delay slot, deferred load), main-EXE vs overlay guard behaviour, the shared +// declaration and the overlay-cache identity. +#include "code_generator.h" +#include "config_loader.h" +#include "control_flow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#endif + +namespace fs = std::filesystem; + +namespace { + +constexpr uint32_t kBase = 0x80010000u; +int failures = 0; + +void check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + ++failures; + } +} + +void append_word(std::vector& bytes, uint32_t word) { + for (int i = 0; i < 4; i++) bytes.push_back(static_cast(word >> (8 * i))); +} + +size_t count_of(const std::string& hay, const std::string& needle) { + size_t n = 0; + for (size_t at = hay.find(needle); at != std::string::npos; + at = hay.find(needle, at + needle.size())) + ++n; + return n; +} + +fs::path write_temp_config(const char* stem, const std::string& body) { + const auto nonce = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + fs::path path = fs::temp_directory_path() / + (std::string(stem) + "-" + std::to_string(nonce) + ".toml"); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << body; + return path; +} + +std::string base_config() { + return R"toml([game] +name = "Mod Immediate Test" +id = "TEST-00000" +exe = "TEST.EXE" +load_address = "0x80010000" +entry_pc = "0x80010000" +text_size = "0x1000" +stack_base = "0x801FFFF0" + +[recompiler] +seeds = "seeds.txt" +out_dir = "generated" +)toml"; +} + +std::string sites_block(std::initializer_list entries) { + std::string out = "\n[mod_immediate]\nsites = [\n"; + for (const auto& e : entries) out += " " + e + ",\n"; + return out + "]\n"; +} + +std::string site(const char* address, const char* expected, int group, + const char* values) { + return std::string("{ address = \"") + address + "\", expected = \"" + expected + + "\", group = " + std::to_string(group) + ", values = [" + values + "] }"; +} + +PSXRecompV4::GameConfig load(const std::string& body) { + fs::path path = write_temp_config("mod-immediate", body); + auto config = PSXRecompV4::load_game_config(path); + fs::remove(path); + return config; +} + +bool load_throws_with(const std::string& body, const char* needle) { + fs::path path = write_temp_config("mod-immediate-bad", body); + bool matched = false; + try { + (void)PSXRecompV4::load_game_config(path); + } catch (const std::exception& e) { + matched = std::string(e.what()).find(needle) != std::string::npos; + if (!matched) std::fprintf(stderr, " (threw: %s)\n", e.what()); + } + fs::remove(path); + return matched; +} + +PSXRecomp::GeneratedFunction generate(std::initializer_list words, + const PSXRecomp::CodeGenConfig& config) { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + exe.header.initial_pc = kBase; + for (uint32_t w : words) append_word(exe.code_data, w); + append_word(exe.code_data, 0x03E00008u); + append_word(exe.code_data, 0x00000000u); + const uint32_t size = static_cast(exe.code_data.size()); + exe.header.file_size = size; + PSXRecomp::Function function{}; + function.start_addr = kBase; + function.end_addr = kBase + size; + function.size = size; + function.name = "imm_site_test"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, cfg); +} + +// Windows has no fork: the Nth probe re-runs this binary as `--exit-probe N`. +const char* g_self = nullptr; +int g_exit_probe = -1; +int g_probe_seq = 0; + +template +bool exits_with_failure(F&& body) { +#if defined(_WIN32) + const int seq = g_probe_seq++; + if (g_exit_probe >= 0) { + if (seq != g_exit_probe) return false; + body(); + std::fflush(nullptr); + std::_Exit(0); + } + std::fflush(nullptr); + const std::string quoted = std::string("\"") + g_self + "\""; + const std::string n = std::to_string(seq); + const intptr_t rc = _spawnl(_P_WAIT, g_self, quoted.c_str(), "--exit-probe", n.c_str(), nullptr); + return rc > 0; +#else + (void)g_probe_seq; + std::fflush(nullptr); + const pid_t pid = fork(); + if (pid == 0) { + if (!std::freopen("/dev/null", "w", stderr)) _exit(3); + body(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return false; + return WIFEXITED(status) && WEXITSTATUS(status) != 0; +#endif +} + +constexpr uint32_t kSltiV0V0_32 = 0x28420020u; // slti v0, v0, 0x20 +constexpr uint32_t kAddiuA0V1 = 0x246402E9u; // addiu a0, v1, 0x2e9 +constexpr uint32_t kLbuV0V1 = 0x906202E9u; // lbu v0, 0x2e9(v1) +constexpr uint32_t kSbA1V1 = 0xA0650308u; // sb a1, 0x308(v1) +constexpr uint32_t kUseV0 = 0x00423021u; // addu a2, v0, v0 (reads v0, writes another) +constexpr uint32_t kBeqZero = 0x10000002u; // b +2 +constexpr uint32_t kNop = 0x00000000u; + +PSXRecomp::CodeGenConfig with_sites(std::vector sites, + bool overlay = false) { + PSXRecomp::CodeGenConfig config{}; + config.mod_immediate_sites = std::move(sites); + config.overlay_mode = overlay; + return config; +} + +PSXRecompV4::ModImmediateSite make(uint32_t address, uint32_t expected, uint32_t group, + std::vector values) { + PSXRecompV4::ModImmediateSite s; + s.address = address; + s.expected = expected; + s.group = group; + s.values = std::move(values); + return s; +} + +void loader() { + check(load(base_config()).mod_immediate_sites.empty(), "no sites by default"); + + const auto cfg = load(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64, 128"), + site("0x8002336C", "0xA0650308", 1, "0x328, 0x368"), + site("0x80023500", "0x28820021", 2, "-1"), // same address, other overlay word + })); + check(cfg.mod_immediate_sites.size() == 3, "three sites parsed"); + if (cfg.mod_immediate_sites.size() == 3) { + const auto& a = cfg.mod_immediate_sites[0]; + check(a.address == 0x80023500u && a.expected == 0x28820020u && a.group == 1u && + a.values.size() == 2 && a.values[0] == 64 && a.values[1] == 128, + "first site fields"); + check(cfg.mod_immediate_sites[1].values[1] == 0x368, "hex values"); + check(cfg.mod_immediate_sites[2].values[0] == 0xFFFF, "negative value wraps to imm16"); + } + + check(load_throws_with(base_config() + sites_block({site("0x80023502", "0x28820020", 1, "64")}), + "not instruction-aligned"), "unaligned address refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x10400002", 1, "64")}), + "not an I-type"), "a branch is refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x0043102B", 1, "64")}), + "not an I-type"), "an R-type is refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 64, "64")}), + "group must be 0..63"), "group 64 refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "")}), + "at least one"), "empty values refused"); + check(load_throws_with(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "70000")}), + "does not fit 16 bits"), "value out of range refused"); + check(load_throws_with(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64"), + site("0xA0023500", "0x28820020", 1, "128")}), + "duplicate mod_immediate"), "duplicate (address, word) refused"); + check(load_throws_with(base_config() + sites_block({site("0x80800000", "0x28820020", 1, "64")}), + "not main RAM"), "address past main RAM refused"); +} + +void emission() { + // slti: one case per value plus the stock default, each with its own immediate. + const auto out = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64, 128})})).full_code; + check(out.find("switch (psx_mod_immediate_select(1u))") != std::string::npos, + "switch on the group selector"); + check(count_of(out, "case 1u:") == 1 && count_of(out, "case 2u:") == 1 && + count_of(out, "default:") >= 1, + "one case per value plus default"); + check(out.find("< 64") != std::string::npos && out.find("< 128") != std::string::npos && + out.find("< 32") != std::string::npos, + "each variant compares against its own immediate"); + + // lbu: offsets differ per variant; deferred loads rewrite every copy. + const auto load_out = generate({kNop, kLbuV0V1, kUseV0, kNop}, + with_sites({make(kBase + 4u, kLbuV0V1, 3, {0x2E9 + 0x100, 0x2E9 - 0x100})})).full_code; + check(count_of(load_out, "switch (psx_mod_immediate_select(3u))") == 1, "load site switched"); + check(load_out.find("1001") != std::string::npos, + "relocated load offset emitted (0x2E9+0x100 = 1001)"); + check(count_of(load_out, "psx_ldd_80010004") >= 4, + "a deferred load writes the temporary in every selector copy"); + check(load_out.find("cpu->gpr[2] = psx_ldd_80010004;") != std::string::npos, + "the deferred load is still written back"); + + // sb store + addiu pointer formation. + const auto st = generate({kSbA1V1, kAddiuA0V1, kNop}, + with_sites({make(kBase, kSbA1V1, 4, {0x328}), + make(kBase + 4u, kAddiuA0V1, 4, {0x400})})).full_code; + check(count_of(st, "switch (psx_mod_immediate_select(4u))") == 2, "store and addiu switched"); + + // A site in a branch delay slot. + const auto ds = generate({kBeqZero, kSltiV0V0_32, kNop, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})})).full_code; + check(ds.find("switch (psx_mod_immediate_select(1u))") != std::string::npos, + "delay-slot site is switched"); + + // No sites: vanilla. + PSXRecomp::CodeGenConfig plain{}; + const auto vanilla = generate({kNop, kSltiV0V0_32, kNop}, plain).full_code; + check(vanilla.find("psx_mod_immediate_select") == std::string::npos, "no sites: vanilla code"); + + // A site elsewhere leaves this instruction alone. + const auto other = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 0x100u, kSltiV0V0_32, 1, {64})})).full_code; + check(other.find("psx_mod_immediate_select") == std::string::npos, "unlisted address: vanilla"); + + // Overlay mode: a different word at a listed address stays vanilla. + const auto ov = generate({kNop, kLbuV0V1, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}, true)).full_code; + check(ov.find("psx_mod_immediate_select") == std::string::npos, + "overlay word mismatch stays vanilla"); + const auto ov_match = generate({kNop, kSltiV0V0_32, kNop}, + with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}, true)).full_code; + check(ov_match.find("psx_mod_immediate_select(1u)") != std::string::npos, + "overlay word match is switched"); +} + +void guards() { + const auto cfg = with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}); + check(!exits_with_failure([&] { generate({kNop, kSltiV0V0_32, kNop}, cfg); }), + "matching word generates"); + check(exits_with_failure([&] { generate({kNop, kLbuV0V1, kNop}, cfg); }), + "a main-EXE word mismatch fails generation"); +} + +void shared_decls() { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + std::vector functions; + { + PSXRecomp::CodeGenerator plain(exe); + check(plain.build_shared_decls_header(functions).find("psx_mod_immediate_select") == + std::string::npos, "no sites: the declarations do not change"); + } + { + const auto cfg = with_sites({make(kBase + 4u, kSltiV0V0_32, 1, {64})}); + PSXRecomp::CodeGenerator gen(exe, cfg); + check(gen.build_shared_decls_header(functions).find( + "extern uint32_t psx_mod_immediate_select(uint32_t group);") != std::string::npos, + "sites: the selector is declared"); + } +} + +void address_adapter_guard() { + const uint32_t original = 0x8ca30000u; /* lw v1,0(a1) */ + auto cfg = with_sites({make(kBase + 4u, original, 3, {4, 8})}); + cfg.mod_instruction_sites.insert(kBase + 4u); + const auto out = generate({kNop, original, kNop}, cfg).full_code; + char guard[128]; + std::snprintf(guard,sizeof guard,"psx_mod_memory_address(cpu, 0x%08Xu, 0x%08Xu,", kBase+4u, original); + check(count_of(out, guard) == 3, "all variants retain the original word for address-adapter guards"); +} + +void overlay_identity() { + auto none = load(base_config()); + auto some = load(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "64")})); + auto other = load(base_config() + sites_block({site("0x80023500", "0x28820020", 1, "65")})); + auto ordered = load(base_config() + sites_block({ + site("0x80023500", "0x28820020", 1, "64"), site("0x80023504", "0x28820020", 1, "64")})); + auto reversed = load(base_config() + sites_block({ + site("0x80023504", "0x28820020", 1, "64"), site("0x80023500", "0x28820020", 1, "64")})); + using PSXRecompV4::overlay_codegen_config_hash; + check(overlay_codegen_config_hash(none) != overlay_codegen_config_hash(some), + "sites enter the overlay-cache identity"); + check(overlay_codegen_config_hash(some) != overlay_codegen_config_hash(other), + "a changed value changes the identity"); + check(overlay_codegen_config_hash(ordered) == overlay_codegen_config_hash(reversed), + "site order does not"); +} + +} // namespace + +int main(int argc, char** argv) { + g_self = argv[0]; + if (argc >= 3 && std::string(argv[1]) == "--exit-probe") g_exit_probe = std::atoi(argv[2]); + loader(); + emission(); + guards(); + shared_decls(); + overlay_identity(); + address_adapter_guard(); + if (failures) { + std::fprintf(stderr, "mod_immediate_codegen_test: %d failure(s)\n", failures); + return 1; + } + std::printf("mod_immediate_codegen_test: OK\n"); + return 0; +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 382f0087..09ea52de 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -277,6 +277,8 @@ if(BUILD_TESTING) "${PSXRECOMP_ROOT}/tools/tests/test_counted_mips_relocations.py") add_test(NAME interp_mod_entry_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_entry_guards.py") + add_test(NAME interp_mod_immediate_guards_test COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_immediate_guards.py") add_test(NAME mirror_pc_preserved_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mirror_pc_preserved_guards.py") add_test(NAME segment_miss_wiring_test COMMAND "${Python3_EXECUTABLE}" @@ -1377,6 +1379,7 @@ if(BUILD_TESTING) add_test(NAME netplay_content_gate_test COMMAND netplay_content_gate_test) add_executable(mod_runtime_test + src/mod_immediate.c tests/test_mod_runtime.cpp src/iso_reader_c.cpp src/host_span_ring.c diff --git a/runtime/include/mod_immediate.h b/runtime/include/mod_immediate.h new file mode 100644 index 00000000..92c0d6b6 --- /dev/null +++ b/runtime/include/mod_immediate.h @@ -0,0 +1,74 @@ +/* mod_immediate.h - [[mod_immediate.sites]]: instruction immediates a trusted + * mod selects at run time. + * + * A title lists guarded I-type instructions in game.toml: + * + * [mod_immediate] + * sites = [ + * { address = "0x80023500", expected = "0x28820020", group = 1, values = [64, 128] }, + * ] + * + * Selector 0 (the default, restored at every session start) runs the original + * instruction. Selector N runs the same instruction with values[N-1] as its + * 16-bit immediate. A mod picks the selector of each group at activation with + * psx_mod_set_immediate_selector (mod_plugins.h). Values are then fixed until + * restart; later setter calls are refused. + * + * The instruction bytes in RAM never change, so static AOT code, cached overlays + * and the interpreter keep matching their original-bytes guards: generated code + * switches on psx_mod_immediate_select(group) per listed instruction, and the + * dirty-RAM interpreter substitutes the word with psx_mod_immediate_patch. + * docs/config_schema.md "Mod immediates" has the config rules. + */ +#ifndef PSX_MOD_IMMEDIATE_H +#define PSX_MOD_IMMEDIATE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PSX_MOD_IMMEDIATE_GROUPS 64 +#define PSX_MOD_IMMEDIATE_VALUES_MAX 15 +#define PSX_MOD_IMMEDIATE_SITES_MAX 4096 + +typedef struct PSXModImmediateSite { + uint32_t address; /* any segment; stored physical */ + uint32_t expected; /* the complete original instruction word */ + uint32_t group; /* 0..63 */ + uint32_t count; /* 1..PSX_MOD_IMMEDIATE_VALUES_MAX */ + uint16_t values[PSX_MOD_IMMEDIATE_VALUES_MAX]; /* imm16 for selector 1..count */ +} PSXModImmediateSite; + +/* Nonzero while a site exists and any selector is nonzero: the interpreter's + * per-instruction gate. */ +extern int g_psx_mod_immediate_live; + +/* The title's sites (game config). Copied, sorted by (address, word) and capped + * at PSX_MOD_IMMEDIATE_SITES_MAX. Returns the number kept. */ +int psx_mod_immediate_set_sites(const PSXModImmediateSite *sites, int count); +int psx_mod_immediate_site_count(void); + +/* Selector of `group`; 0 for an out-of-range group. Called by generated code. */ +uint32_t psx_mod_immediate_select(uint32_t group); +/* Session reset: every selector back to 0 (stock instructions). */ +void psx_mod_immediate_reset(void); +/* Host lifecycle only: begin_session resets choices and leaves setters locked. + * The mod runtime opens only for active activation callbacks, after cleanup, + * then seals before guest execution. Online activation rejects nonzero choices; + * the host checks session_valid even if the plugin ignores the setter result. */ +void psx_mod_immediate_begin_session(int online); +void psx_mod_immediate_begin_activation(void); +void psx_mod_immediate_end_activation(void); +int psx_mod_immediate_session_valid(void); + +/* The instruction the interpreter executes at `pc`: `insn` itself unless a + * listed site with that exact word has a nonzero selector. */ +uint32_t psx_mod_immediate_patch(uint32_t pc, uint32_t insn); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 59493d55..c150e5a0 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -927,6 +927,21 @@ int psx_mod_set_disc_speed(uint32_t divisor, int psx_mod_set_draw_distance_clamp(int enabled); int psx_mod_draw_distance_clamp_enabled(void); +/* + * Select the immediate variant of one [[mod_immediate.sites]] group + * (mod_immediate.h). Selector 0 runs the stock instructions; N runs + * values[N-1] of every site in `group`. The instruction bytes in RAM never + * change, so static and cached native code stay valid. Returns 1 when the + * title configured sites, 0 otherwise (nothing the guest sees changes), and + * rejects a group >= 64 or a selector > 15. Reset to 0 at every session start: + * call it from activation, before the guest runs. The caller owns the memory + * layout the selector implies (a savestate or card written under another + * selector is not interchangeable). Only offline activation may select a value; + * the host seals choices before guest execution. Later setter calls fail. + */ +int psx_mod_set_immediate_selector(uint32_t group, uint32_t selector); +uint32_t psx_mod_immediate_selector(uint32_t group); + /* Controller presentation values exposed to trusted game-owned plugins. */ enum { PSX_MOD_CONTROLLER_ANALOG = 1, diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index 9f37b58e..36c36680 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -126,8 +126,9 @@ * already holds every release-line callback; shards built against either * v28 or v29 are rejected and recompiled. * v31/v32: reserved for the experimental selector/adapter stack. - * v33: guarded load/store effective-address adapter on the master layout. */ -#define PSX_OVERLAY_ABI_VERSION 33 + * v33: guarded load/store effective-address adapter on the master layout. + * v34: frozen, activation-only immediate selectors on the v33 layout. */ +#define PSX_OVERLAY_ABI_VERSION 34 /* Process-lifetime overlay candidate capacity. Every accepted manifest F * record consumes one slot, even when another DLL carries an identical @@ -391,6 +392,8 @@ typedef struct OverlayCallbacks { void (*mod_instruction)(CPUState*, uint32_t, uint32_t); /* Guarded load/store effective-address adapters (ABI v33). */ uint32_t (*mod_memory_address)(const CPUState*, uint32_t, uint32_t, uint32_t); + /* [[mod_immediate.sites]] selector of a group (ABI v34); NULL = stock. */ + uint32_t (*mod_immediate_select)(uint32_t group); } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 3ce89713..0473b4bf 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -277,6 +277,9 @@ uint32_t psx_mod_memory_address(const CPUState *cpu, uint32_t pc, uint32_t word, overlay_flush_cycles(); return g_cbs.mod_memory_address ? g_cbs.mod_memory_address(cpu, pc, word, address) : address; } +uint32_t psx_mod_immediate_select(uint32_t group) { + return g_cbs.mod_immediate_select ? g_cbs.mod_immediate_select(group) : 0u; +} 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/runtime.cmake b/runtime/runtime.cmake index 58be92e4..755881d2 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -425,6 +425,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/debug_trace_ranges.c ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c ${PSXRECOMP_ROOT}/runtime/src/draw_distance.c + ${PSXRECOMP_ROOT}/runtime/src/mod_immediate.c ${PSXRECOMP_ROOT}/runtime/src/game_dispatch_compat.c ${PSXRECOMP_ROOT}/runtime/src/psx_segment_miss.c ${PSXRECOMP_ROOT}/runtime/src/fntrace.c diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 143a2a83..afe81bf5 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -39,6 +39,7 @@ #include "psx_segment_miss.h" /* segment misses in static game code (§5.5) */ #include "draw_distance.h" /* [[draw_distance.clamp]] sites */ #include "mod_plugins.h" /* instruction guards and effective-address adapters */ +#include "mod_immediate.h" /* [[mod_immediate.sites]] selectors */ #include #include @@ -1530,6 +1531,9 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn); const uint32_t original_instruction = insn; + /* [[mod_immediate.sites]]: execute the selected immediate variant. The + * guest's bytes (and the guard above) stay the original instruction. */ + if (g_psx_mod_immediate_live) insn = psx_mod_immediate_patch(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. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b21fe616..95300459 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -56,6 +56,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "overlay_backend.h" #include "gpu.h" #include "draw_distance.h" +#include "mod_immediate.h" #include "display_scanout.h" #include "pgxp.h" #include "pgxp_session.h" @@ -1997,6 +1998,7 @@ static void reset_mod_owned_presentation(void) { psx_mod_set_retained_scene_predicate(nullptr); psx_mod_set_adaptive_backdrop_preload(0); (void)psx_mod_set_draw_distance_clamp(0); + psx_mod_immediate_reset(); /* [[mod_immediate.sites]] selectors */ /* [timing] guest_cycle_scale mod gate: shut until this session's * activation opens it (an online match clears the plan, so a mod-gated * scale never carries into netplay). */ @@ -17904,6 +17906,24 @@ int main(int argc, char** argv) { (void)psx_draw_distance_set_clamp_sites(sites.data(), (int)sites.size()); } + { + /* [[mod_immediate.sites]]: the interpreter's copy of the + * sites the generated code switches on. */ + std::vector sites; + sites.reserve(gc.mod_immediate_sites.size()); + for (const auto& site : gc.mod_immediate_sites) { + PSXModImmediateSite out{}; + out.address = site.address; + out.expected = site.expected; + out.group = site.group; + out.count = (uint32_t)site.values.size(); + for (size_t i = 0; i < site.values.size() && + i < PSX_MOD_IMMEDIATE_VALUES_MAX; i++) + out.values[i] = site.values[i]; + sites.push_back(out); + } + (void)psx_mod_immediate_set_sites(sites.data(), (int)sites.size()); + } { std::vector addresses, expected, thresholds; std::vector object_regs, x_regs, z_regs, y_regs; @@ -19861,13 +19881,6 @@ int main(int argc, char** argv) { * activates and the session stays vanilla. */ auto start_mod_session = [&](bool netplay) -> bool { mod_runtime_set_progress_hook(starvation_watchdog_heartbeat); - std::string save_scope, save_error; - if (!PSXRecompV4::mod_runtime_savestate_scope(save_scope, &save_error) || - !savestate_set_content_scope(save_scope.c_str())) { - (void)savestate_set_content_scope("invalid"); - std::fprintf(stderr, "psxrecomp: cannot establish save-state scope: %s\n", save_error.c_str()); - return false; - } /* Clear game-owned controller overrides/policies and load/disc-speed * choices first so disabling a package cannot leave its prior state * latched across a soft return. */ @@ -19901,7 +19914,19 @@ int main(int argc, char** argv) { * sandboxed own view only; the shared game keeps the stock cull. */ gpu_ws_set_local_view_only( netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); + psx_mod_immediate_begin_session(netplay ? 1 : 0); mod_runtime_activate_plugins(); + if (!psx_mod_immediate_session_valid()) { + std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n"); + return false; + } + std::string save_scope, save_error; + if (!PSXRecompV4::mod_runtime_savestate_scope(save_scope, &save_error) || + !savestate_set_content_scope(save_scope.c_str())) { + (void)savestate_set_content_scope("invalid"); + std::fprintf(stderr, "psxrecomp: cannot establish save-state scope: %s\n", save_error.c_str()); + return false; + } if (netplay && memcard_extension_codec_active()) { std::fprintf(stderr, "psxrecomp: selected mod uses extended memory cards, which are unsupported in netplay\n"); diff --git a/runtime/src/mod_immediate.c b/runtime/src/mod_immediate.c new file mode 100644 index 00000000..f39a82d7 --- /dev/null +++ b/runtime/src/mod_immediate.c @@ -0,0 +1,107 @@ +/* mod_immediate.c - the [[mod_immediate.sites]] store and selectors + * (mod_immediate.h). */ +#include "mod_immediate.h" +#include "mod_plugins.h" + +#include +#include + +int g_psx_mod_immediate_live = 0; + +static PSXModImmediateSite s_imm_sites[PSX_MOD_IMMEDIATE_SITES_MAX]; +static int s_imm_site_count = 0; +static uint8_t s_selector[PSX_MOD_IMMEDIATE_GROUPS]; +static int s_activating, s_online, s_rejected_online; + +static void imm_update_live(void) { + int any = 0; + for (int g = 0; g < PSX_MOD_IMMEDIATE_GROUPS; g++) any |= s_selector[g] != 0u; + g_psx_mod_immediate_live = (any && s_imm_site_count > 0) ? 1 : 0; +} + +static int imm_site_cmp(const void *a, const void *b) { + const PSXModImmediateSite *x = (const PSXModImmediateSite *)a; + const PSXModImmediateSite *y = (const PSXModImmediateSite *)b; + if (x->address != y->address) return x->address < y->address ? -1 : 1; + return (x->expected > y->expected) - (x->expected < y->expected); +} + +int psx_mod_immediate_set_sites(const PSXModImmediateSite *sites, int count) { + if (!sites || count < 0) count = 0; + if (count > PSX_MOD_IMMEDIATE_SITES_MAX) { + fprintf(stderr, + "psxrecomp: [[mod_immediate.sites]] lists %d sites; keeping the " + "first %d\n", count, PSX_MOD_IMMEDIATE_SITES_MAX); + count = PSX_MOD_IMMEDIATE_SITES_MAX; + } + int kept = 0; + for (int i = 0; i < count; i++) { + if (sites[i].group >= PSX_MOD_IMMEDIATE_GROUPS || sites[i].count == 0u || + sites[i].count > PSX_MOD_IMMEDIATE_VALUES_MAX) + continue; + s_imm_sites[kept] = sites[i]; + s_imm_sites[kept].address &= 0x1FFFFFFFu; + kept++; + } + if (kept > 1) qsort(s_imm_sites, (size_t)kept, sizeof s_imm_sites[0], imm_site_cmp); + s_imm_site_count = kept; + imm_update_live(); + return kept; +} + +int psx_mod_immediate_site_count(void) { return s_imm_site_count; } + +uint32_t psx_mod_immediate_select(uint32_t group) { + return group < PSX_MOD_IMMEDIATE_GROUPS ? s_selector[group] : 0u; +} + +void psx_mod_immediate_reset(void) { + s_activating = s_online = s_rejected_online = 0; + for (int g = 0; g < PSX_MOD_IMMEDIATE_GROUPS; g++) s_selector[g] = 0u; + imm_update_live(); +} + +void psx_mod_immediate_begin_session(int online) { + psx_mod_immediate_reset(); + s_online = online != 0; +} + +void psx_mod_immediate_begin_activation(void) { s_activating = 1; } +void psx_mod_immediate_end_activation(void) { s_activating = 0; } +int psx_mod_immediate_session_valid(void) { return !s_rejected_online; } + +uint32_t psx_mod_immediate_patch(uint32_t pc, uint32_t insn) { + const uint32_t phys = pc & 0x1FFFFFFFu; + int lo = 0, hi = s_imm_site_count; + while (lo < hi) { + const int mid = lo + (hi - lo) / 2; + if (s_imm_sites[mid].address < phys) lo = mid + 1; + else hi = mid; + } + for (; lo < s_imm_site_count && s_imm_sites[lo].address == phys; lo++) { + const PSXModImmediateSite *site = &s_imm_sites[lo]; + if (site->expected != insn) continue; + const uint32_t sel = s_selector[site->group]; + if (sel == 0u || sel > site->count) return insn; + return (insn & 0xFFFF0000u) | site->values[sel - 1u]; + } + return insn; +} + +/* Selected only during offline activation; immutable until the next boot. */ +int psx_mod_set_immediate_selector(uint32_t group, uint32_t selector) { + if (group >= PSX_MOD_IMMEDIATE_GROUPS || selector > PSX_MOD_IMMEDIATE_VALUES_MAX) + return 0; + if (!s_activating) return 0; + if (s_online && selector != 0u) { + s_rejected_online = 1; + return 0; + } + s_selector[group] = (uint8_t)selector; + imm_update_live(); + return s_imm_site_count > 0; +} + +uint32_t psx_mod_immediate_selector(uint32_t group) { + return psx_mod_immediate_select(group); +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index acc93f70..b43d46cf 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -8,6 +8,7 @@ #include "mod_packages.h" #include "mod_plugins.h" #include "mod_netplay.h" +#include "mod_immediate.h" #include "psx_netplay.h" #include "gpu.h" #include "gpu_hd_textures.h" @@ -2477,16 +2478,23 @@ bool mod_runtime_savestate_scope(std::string& scope, std::string* error) { } const bool isolated = std::any_of(s.plan.ordered.begin(), s.plan.ordered.end(), [](const ModPackage* package) { return package->save_compatibility == "isolated"; }); - if (!isolated) return true; - const std::string& fingerprint = s.plan.fingerprint; - if (!s.plan.ok || fingerprint.size() != 64 || - !std::all_of(fingerprint.begin(), fingerprint.end(), [](char c) { - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); - }) || !netplay_resources_verified(s.plan)) { - if (error && error->empty()) *error = "Isolated save states require a verified content plan"; - return false; + if (isolated) { + const std::string& fingerprint = s.plan.fingerprint; + if (!s.plan.ok || fingerprint.size() != 64 || + !std::all_of(fingerprint.begin(), fingerprint.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }) || !netplay_resources_verified(s.plan)) { + if (error && error->empty()) *error = "Isolated save states require a verified content plan"; + return false; + } + scope = fingerprint; + } + if (g_psx_mod_immediate_live) { + std::string identity = "psxrecomp/fixed-immediates/v1\n" + scope + "\n"; + for (uint32_t group = 0; group < PSX_MOD_IMMEDIATE_GROUPS; ++group) + identity += std::to_string(psx_mod_immediate_select(group)) + ","; + scope = sha256_text(identity); } - scope = fingerprint; return true; } @@ -2936,6 +2944,7 @@ extern "C" void mod_runtime_activate_plugins(void) { s.audio_tracks.clear(); s.extent_start = 0; s.activating = true; + psx_mod_immediate_begin_activation(); for (const ModResolution::Plugin& plugin : s.plan.plugins) { HostLaunchTimingScope plugin_timing(HOST_LAUNCH_PLUGIN_CALLBACK, plugin.package_id.c_str(), plugin.feature_id.c_str()); @@ -2943,6 +2952,7 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } + psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); timing.success(); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 4a939b35..5929d3b0 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2636,6 +2636,8 @@ static void init_callbacks(void) { s_callbacks.mod_instruction = psx_mod_instruction; extern uint32_t psx_mod_memory_address(const CPUState*, uint32_t, uint32_t, uint32_t); s_callbacks.mod_memory_address = psx_mod_memory_address; + extern uint32_t psx_mod_immediate_select(uint32_t group); + s_callbacks.mod_immediate_select = psx_mod_immediate_select; } /* 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_draw_distance_interp.c b/runtime/tests/test_draw_distance_interp.c index a00a4a2e..3d18520e 100644 --- a/runtime/tests/test_draw_distance_interp.c +++ b/runtime/tests/test_draw_distance_interp.c @@ -22,6 +22,7 @@ #define PSX_HAS_GAME_DISPATCH 1 #include "../src/draw_distance.c" #include "../src/gpu.c" +#include "../src/mod_immediate.c" #include "../src/dirty_ram_interp.c" #include @@ -51,9 +52,10 @@ uint32_t i_mask; uint64_t psx_cycle_count; uint32_t g_psx_mod_function_entry_hooks; uint32_t g_psx_mod_instruction_hooks; -static uint32_t address_calls, shadow_address; +static uint32_t address_calls, shadow_address, address_word; uint32_t psx_mod_memory_address(const CPUState *cpu, uint32_t pc, uint32_t word, uint32_t address) { (void)cpu; (void)pc; (void)word; + address_word = word; ++address_calls; return address + 0x1000u; } @@ -355,6 +357,19 @@ static void test_memory_addresses(void) { step(&cpu, TEXT_LO, 0x8ca50000u); /* lw a1,0(a1) */ CHECK(cpu.gpr[5] == 0x12345678u && shadow_address == 0x80009000u, "load destination may alias original base register"); + const uint32_t original = 0x8ca30000u; /* lw v1,0(a1) */ + const PSXModImmediateSite immediate = {TEXT_LO, original, 3u, 1u, {4}}; + psx_mod_immediate_set_sites(&immediate, 1); + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_set_immediate_selector(3, 1), "select relocated offset at activation"); + psx_mod_immediate_end_activation(); + memset(&cpu, 0, sizeof cpu); cpu.read_word = psx_read_word; + cpu.gpr[5] = 0x80008000u; put_word(0x80009004u, 0x76543210u); + step(&cpu, TEXT_LO, original); + CHECK(cpu.gpr[3] == 0x76543210u && address_word == original && shadow_address == 0x80009004u, + "selected immediate and address adapter share original guard and resolved PGXP address"); + psx_mod_immediate_reset(); psx_mod_immediate_set_sites(NULL, 0); g_psx_mod_instruction_hooks = 0; } diff --git a/runtime/tests/test_interp_mod_immediate_guards.py b/runtime/tests/test_interp_mod_immediate_guards.py new file mode 100644 index 00000000..d79fbb24 --- /dev/null +++ b/runtime/tests/test_interp_mod_immediate_guards.py @@ -0,0 +1,42 @@ +"""Pin the dirty-RAM interpreter's [[mod_immediate.sites]] call site: the word +substitution must follow the guarded instruction hook (which compares the +original word) and precede every use of `insn` for decode and execution.""" +import pathlib +import re + +SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "dirty_ram_interp.c" + + +def test_interp_substitutes_after_instruction_hook(): + text = SRC.read_text(encoding="utf-8") + start = text.index("static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn,\n" + " uint32_t *next_pc_out) {") + body = text[start:text.index("static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn,", start + 10)] + patch = "if (g_psx_mod_immediate_live) insn = psx_mod_immediate_patch(pc, insn);" + hook = "if (g_psx_mod_instruction_hooks) psx_mod_instruction(cpu, pc, insn);" + assert body.count(patch) == 1 + assert body.index(hook) < body.index("const uint32_t original_instruction = insn;") < body.index(patch), "hook guards the original word" + # decode of the load/store fields happens after the substitution + assert body.index(patch) < body.index("const uint32_t ld_op = op_field(insn);") + + +def test_interp_includes_header(): + text = SRC.read_text(encoding="utf-8") + assert re.search(r'#include "mod_immediate\.h"', text) + +def test_session_seals_before_scope_and_guest(): + text = (SRC.parent / "main.cpp").read_text(encoding="utf-8") + body = text[text.index("auto start_mod_session ="):] + assert body.index("psx_mod_immediate_begin_session(") < body.index("mod_runtime_activate_plugins();") + assert body.index("mod_runtime_activate_plugins();") < body.index("mod_runtime_savestate_scope(") + text = (SRC.parent / "mod_runtime.cpp").read_text(encoding="utf-8") + body = text[text.index('extern "C" void mod_runtime_activate_plugins(void)'):] + assert body.index("mod_reset_plugin_sessions();") < body.index("psx_mod_immediate_begin_activation();") + assert body.index("mod_invoke_activation_plugin(plugin.id);") < body.index("psx_mod_immediate_end_activation();") + + + +if __name__ == "__main__": + test_interp_substitutes_after_instruction_hook() + test_interp_includes_header() + test_session_seals_before_scope_and_guest() diff --git a/runtime/tests/test_mod_immediate.c b/runtime/tests/test_mod_immediate.c new file mode 100644 index 00000000..bb5c8342 --- /dev/null +++ b/runtime/tests/test_mod_immediate.c @@ -0,0 +1,188 @@ +/* [[mod_immediate.sites]] runtime store, selectors and the interpreter's word + * substitution (mod_immediate.c): physical addresses, (address, word) lookup, + * sorting, the cap and its log line, invalid entries dropped, empty and NULL + * lists, selector range checks, the live gate and session reset, and + * psx_mod_immediate_patch swapping exactly the imm16 of a listed word. + * The interpreter call site is pinned by test_interp_mod_immediate_guards.py. */ +#include "../src/mod_immediate.c" + +#include +#include +#include +#if defined(_WIN32) +#include +#define dup _dup +#define dup2 _dup2 +#define fileno _fileno +#else +#include +#endif + +static int failures; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); \ + fputc('\n', stderr); \ + ++failures; \ + } \ + } while (0) + +#define SLTI_V0_V0_20 0x28420020u /* slti v0, v0, 0x20 */ +#define SLTIU_T2_A1_20 0x2CAA0020u /* sltiu t2, a1, 0x20 */ +#define LBU_V0_V1_2E9 0x906202E9u /* lbu v0, 0x2e9(v1) */ + +static const char *s_capture_path = "mod_immediate_unit_stderr.txt"; + +static void capture_stderr(void (*fn)(void), char *buf, size_t cap) { + FILE *tmp = fopen(s_capture_path, "w+b"); + buf[0] = '\0'; + if (!tmp) { + CHECK(0, "cannot open the stderr capture file %s", s_capture_path); + fn(); + return; + } + fflush(stderr); + int saved = dup(fileno(stderr)); + dup2(fileno(tmp), fileno(stderr)); + fn(); + fflush(stderr); + dup2(saved, fileno(stderr)); + close(saved); + rewind(tmp); + size_t n = fread(buf, 1, cap - 1, tmp); + buf[n] = '\0'; + fclose(tmp); + remove(s_capture_path); +} + +/* ---- store ----------------------------------------------------------------- */ +#define BIG_N (PSX_MOD_IMMEDIATE_SITES_MAX + 44) +static PSXModImmediateSite big[BIG_N]; +static void set_big(void) { (void)psx_mod_immediate_set_sites(big, BIG_N); } + +static PSXModImmediateSite mk(uint32_t addr, uint32_t word, uint32_t group, uint32_t n, + uint16_t a, uint16_t b) { + PSXModImmediateSite s; + memset(&s, 0, sizeof s); + s.address = addr; s.expected = word; s.group = group; s.count = n; + s.values[0] = a; s.values[1] = b; + return s; +} + +static void test_store(void) { + const PSXModImmediateSite sites[] = { + mk(0x80020010u, SLTI_V0_V0_20, 1, 2, 0x40, 0x80), + mk(0xA0020000u, LBU_V0_V1_2E9, 2, 1, 0x3E9, 0), /* KSEG1 spelling */ + mk(0x00020010u, SLTIU_T2_A1_20, 1, 2, 0x40, 0x80), /* same address, other word */ + mk(0x80020020u, SLTI_V0_V0_20, 64, 1, 1, 0), /* bad group: dropped */ + mk(0x80020030u, SLTI_V0_V0_20, 1, 0, 0, 0), /* no values: dropped */ + }; + CHECK(psx_mod_immediate_set_sites(sites, 5) == 3, "three valid sites stored"); + CHECK(psx_mod_immediate_site_count() == 3, "count 3"); + CHECK(s_imm_sites[0].address == 0x00020000u && s_imm_sites[1].address == 0x00020010u && + s_imm_sites[2].address == 0x00020010u && s_imm_sites[1].expected < s_imm_sites[2].expected, + "stored sorted by (physical address, word)"); + + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + (void)psx_mod_set_immediate_selector(1, 1); + CHECK(psx_mod_immediate_patch(0x80020010u, SLTI_V0_V0_20) == 0x28420040u, + "KSEG0 lookup, selector 1 -> 0x40"); + CHECK(psx_mod_immediate_patch(0x00020010u, SLTIU_T2_A1_20) == 0x2CAA0040u, + "the other word at the shared address is its own site"); + CHECK(psx_mod_immediate_patch(0x80020010u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "an unlisted word at a listed address is unchanged"); + CHECK(psx_mod_immediate_patch(0x80020000u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "group 2 is still selector 0"); + (void)psx_mod_set_immediate_selector(2, 1); + CHECK(psx_mod_immediate_patch(0x80020000u, LBU_V0_V1_2E9) == 0x906203E9u, + "KSEG1 entry found through KSEG0, imm16 swapped"); + CHECK(psx_mod_immediate_patch(0x80020004u, LBU_V0_V1_2E9) == LBU_V0_V1_2E9, + "between entries is unchanged"); + + CHECK(psx_mod_immediate_set_sites(NULL, 5) == 0, "NULL list stores none"); + CHECK(psx_mod_immediate_set_sites(sites, -1) == 0, "negative count stores none"); + CHECK(psx_mod_immediate_patch(0x80020010u, SLTI_V0_V0_20) == SLTI_V0_V0_20, + "empty store is unchanged"); + + for (int i = 0; i < BIG_N; i++) + big[i] = mk(0x80040000u + 4u * (uint32_t)i, SLTI_V0_V0_20, 3, 1, 0x40, 0); + char log[512]; + capture_stderr(set_big, log, sizeof log); + CHECK(psx_mod_immediate_site_count() == PSX_MOD_IMMEDIATE_SITES_MAX, + "over-long list stored %d", psx_mod_immediate_site_count()); + CHECK(strstr(log, "keeping the first 4096") != NULL, "overflow not logged (got \"%s\")", log); + (void)psx_mod_immediate_set_sites(NULL, 0); +} + +/* ---- selectors ------------------------------------------------------------- */ +static void test_selectors(void) { + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_set_immediate_selector(1, 1) == 0, "no sites: the setter says so"); + CHECK(psx_mod_immediate_selector(1) == 1u, "the selector is still recorded"); + CHECK(!g_psx_mod_immediate_live, "no sites: interpreter gate stays off"); + const PSXModImmediateSite one = mk(0x80020000u, SLTI_V0_V0_20, 1, 2, 0x40, 0x80); + (void)psx_mod_immediate_set_sites(&one, 1); + CHECK(g_psx_mod_immediate_live, "sites with a nonzero selector: gate on"); + CHECK(psx_mod_set_immediate_selector(1, 0) == 1, "sites: the setter says so"); + CHECK(!g_psx_mod_immediate_live, "selector 0: gate off"); + CHECK(psx_mod_set_immediate_selector(64, 1) == 0 && psx_mod_set_immediate_selector(1, 16) == 0, + "out-of-range group / selector refused"); + CHECK(psx_mod_immediate_select(1) == 0u && psx_mod_immediate_select(99) == 0u, + "unset and out-of-range groups read 0"); + (void)psx_mod_set_immediate_selector(1, 2); + CHECK(psx_mod_immediate_select(1) == 2u && g_psx_mod_immediate_live, "selector 2 live"); + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_immediate_select(1) == 0u && !g_psx_mod_immediate_live, "reset clears"); + /* A selector past the site's value count runs stock. */ + (void)psx_mod_set_immediate_selector(1, 5); + CHECK(psx_mod_immediate_patch(0x80020000u, SLTI_V0_V0_20) == SLTI_V0_V0_20, + "selector beyond the value count is stock"); + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + (void)psx_mod_immediate_set_sites(NULL, 0); +} + +static void test_session_boundary(void) { + const PSXModImmediateSite one = mk(0x80020000u, SLTI_V0_V0_20, 1, 2, 0x40, 0x80); + psx_mod_immediate_set_sites(&one, 1); + psx_mod_immediate_reset(); + CHECK(!psx_mod_set_immediate_selector(1, 1), "closed before activation"); + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_set_immediate_selector(1, 2), "offline activation selects128"); + psx_mod_immediate_end_activation(); + CHECK(!psx_mod_set_immediate_selector(1, 1) && !psx_mod_set_immediate_selector(1, 0), + "both mutation and disabling require restart"); + CHECK(psx_mod_immediate_select(1) == 2, "rejected changes retain128"); + psx_mod_immediate_begin_session(1); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_immediate_select(1) == 0 && !g_psx_mod_immediate_live, "online starts stock"); + CHECK(!psx_mod_set_immediate_selector(1, 1), "online nonzero refused"); + psx_mod_immediate_end_activation(); + CHECK(!psx_mod_immediate_session_valid() && psx_mod_immediate_select(1) == 0, + "ignored online refusal still invalidates launch"); + psx_mod_immediate_begin_session(0); + psx_mod_immediate_begin_activation(); + CHECK(psx_mod_immediate_session_valid() && psx_mod_immediate_select(1) == 0, "next offline session reset"); + CHECK(psx_mod_set_immediate_selector(1, 1), "new offline session can select64"); + psx_mod_immediate_end_activation(); + psx_mod_immediate_reset(); +} + +int main(int argc, char **argv) { + if (argc > 1) s_capture_path = argv[1]; + test_store(); + test_selectors(); + test_session_boundary(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + puts("PASS mod immediate store, selectors and patch"); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 0a6cd56d..f5336a6f 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -4,6 +4,7 @@ #include "host_file_identity.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "mod_immediate.h" #include "psx_sha256.h" #include "psx_lobby_client.h" @@ -284,7 +285,12 @@ static void test_disc_audio_plugin() { if(!psx_mod_append_cdda_tracks(tracks,2,starts,counts) || starts[0]!=150 || starts[1]!=153 || counts[0]!=3 || counts[1]!=2) failures++; } +static int immediate_activation_choice, immediate_reset_accepted; +static void test_immediate_reset(void) { + immediate_reset_accepted = psx_mod_set_immediate_selector(1, 2); +} static void test_media_plugin(void) { + if (immediate_activation_choice) psx_mod_set_immediate_selector(1, immediate_activation_choice); media_access = psx_mod_current_resource_bytes("rom", &media_snapshot, &media_size); const uint8_t* foreign = nullptr; uint64_t size = 0; @@ -1388,6 +1394,38 @@ int main(int argc, char** argv) { std::string save_scope; check(PSXRecompV4::mod_runtime_savestate_scope(save_scope,&error) && save_scope.empty(), "shared packages retain historical save-state paths"); + const PSXModImmediateSite site = {0x80020000u, 0x28420020u, 1u, 2u, {64, 128}}; + psx_mod_immediate_set_sites(&site, 1); + check(psx_mod_register_session_reset_plugin("immediate.test.reset",test_immediate_reset), + "register reset-boundary probe"); + immediate_activation_choice = 1; + psx_mod_immediate_begin_session(0); + mod_runtime_activate_plugins(); + check(!immediate_reset_accepted && psx_mod_immediate_select(1)==1, + "cleanup cannot select; active plugin selects64"); + std::string scope64, scope128; + check(PSXRecompV4::mod_runtime_savestate_scope(scope64,&error) && scope64.size()==64, + "non-stock selectors isolate even shared-package savestates"); + check(!psx_mod_set_immediate_selector(1, 2), "live selection refused"); + check(PSXRecompV4::mod_runtime_savestate_scope(save_scope,&error) && save_scope==scope64, + "failed live selection preserves current state compatibility"); + immediate_activation_choice = 2; + psx_mod_immediate_begin_session(0); + mod_runtime_activate_plugins(); + check(psx_mod_immediate_select(1)==2, "next boot selects128"); + check(PSXRecompV4::mod_runtime_savestate_scope(scope128,&error) && scope128.size()==64 && scope128!=scope64, + "128-slot session cannot read64-slot state namespace"); + psx_mod_immediate_begin_session(1); + mod_runtime_activate_plugins(); + check(!psx_mod_immediate_session_valid() && psx_mod_immediate_select(1)==0, + "online activation cannot enable selectors even if plugin ignores refusal"); + immediate_activation_choice = 0; + psx_mod_immediate_begin_session(0); + mod_runtime_activate_plugins(); + check(PSXRecompV4::mod_runtime_savestate_scope(save_scope,&error) && save_scope.empty(), + "stock selectors restore historical paths"); + psx_mod_immediate_reset(); + psx_mod_immediate_set_sites(nullptr, 0); } const std::string host_media_fp = PSXRecompV4::mod_runtime_plan_fingerprint_portable(); check(host_media_fp.size() == 64, "verified external media has a portable netplay identity"); diff --git a/runtime/tests/test_ws_cull_edge_interp.c b/runtime/tests/test_ws_cull_edge_interp.c index 50d662c0..149452d4 100644 --- a/runtime/tests/test_ws_cull_edge_interp.c +++ b/runtime/tests/test_ws_cull_edge_interp.c @@ -20,6 +20,7 @@ * dropped at link time (LTO plus section GC / dead_strip), so only the stubs * below are needed. */ #include "../src/gpu.c" +#include "../src/mod_immediate.c" #include "../src/dirty_ram_interp.c" #include