From 6a76eb452b2a9670751d1f10cdeddf099ffda26a Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:42:57 -0700 Subject: [PATCH] recompiler: compile segment variants of game code from segment-qualified seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seed that names the image's bytes in another segment than its link segment (0xA00100F0 for a KUSEG-linked EXE, 0x00012345 for a KSEG0 one) now compiles a segment variant (docs/SEGMENT_AWARE_CODE.md §5.4): the home functions the seed reaches through direct edges (calls, tail jumps, split pieces, fall-throughs, call returns; a seed at a CPS continuation roots its owner, an alias entry its alias group) are compiled again through a view of the image in that segment. The variant's names, rows and every PC it bakes are that segment's, and a KSEG1 variant charges a fetch per instruction (#429's rule through B's runtime_pc()). A cached variant is the home C with the segment swapped. Exact-match config sites move into the variant's segment, so a variant gets every hook the home body gets. A seed that names no home dispatch entry stops the build, and so does a seed in a segment that does not map physical memory (0x20000000-0x7FFFFFFF or KSEG2: Beetle's addr_mask leaves them unmasked, so they address no RAM and are no alias of the image's bytes). tools/collect_game_misses.py reports such a segment miss instead of writing it as a seed. The dispatch table holds a row per compiled segment of a word, adjacent and in PC order; when any word has more than one row the lookup (indexed and binary search) searches the word's rows for the exact PC. Two rows for one PC remain a build error. A title without variants gets the same dispatch source as before. A jump-table case whose value is in another segment than its body is no longer a local case: the default tail-transfer dispatches it with its full PC. This applies to every compile. In a variant it is the common case (a KSEG0 variant of KUSEG-linked code). A home or overlay compile has one only when its table holds another segment's PCs of its own bytes, for example a KSEG0-compiled overlay shard of code that runs at KUSEG. Such a case used to continue in the KSEG0 body with KSEG0 links and fetch tags. Now it dispatches: a game-text PC with no row in its segment is a recorded segment miss, and #417's gate sends an overlay PC to the interpreter; both run it at its own PC (PR E returns overlay code to native per-segment shards). R4's game C and its 26 overlay shard sources are unchanged. Ledger: segment-variants and kseg1-fetch-charge close (KNOWN_GAPS is empty) and become regression guards; resume-pc-segment now requires each row's key and resume PC to be in its body's segment, and segment-variants checks every PC a variant body bakes. New segment_variants_test; kuseg_dispatch_lookup and emitter_runtime_pc_test cover variant rows, closure, equality with the home C, per-instruction KSEG1 charging, foreign jump-table cases at home and in variants, data stubs (no row), alias groups declared for every shard and seeds outside the physical segments. Co-Authored-By: Claude Opus 5.5 --- recompiler/CMakeLists.txt | 30 ++ recompiler/include/code_generator.h | 26 +- recompiler/include/ps1_exe_parser.h | 10 + recompiler/src/code_generator.cpp | 16 +- recompiler/src/game_dispatch_emitter.cpp | 125 +++++--- recompiler/src/game_dispatch_emitter.h | 34 +- recompiler/src/main_psx.cpp | 133 ++++++-- recompiler/src/segment_variants.cpp | 202 ++++++++++++ recompiler/src/segment_variants.h | 80 +++++ recompiler/tests/emitter_runtime_pc_test.cpp | 42 ++- recompiler/tests/segment_variants_test.cpp | 273 ++++++++++++++++ .../tests/test_kuseg_dispatch_lookup.py | 296 +++++++++++++++--- .../tests/test_segment_aware_codegen.py | 31 +- tools/collect_game_misses.py | 20 +- tools/tests/test_collect_game_misses.py | 11 +- 15 files changed, 1208 insertions(+), 121 deletions(-) create mode 100644 recompiler/src/segment_variants.cpp create mode 100644 recompiler/src/segment_variants.h create mode 100644 recompiler/tests/segment_variants_test.cpp diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index d5938464..0c54fbc3 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -110,6 +110,7 @@ set(PSXRECOMP_GAME_SOURCES src/config_loader.cpp src/recompiler_patch.cpp src/game_dispatch_emitter.cpp + src/segment_variants.cpp src/main_psx.cpp ) @@ -626,6 +627,35 @@ if(BUILD_TESTING) set_tests_properties(emitter_runtime_pc_test_legacy PROPERTIES ENVIRONMENT "PSX_CPS=0") + # Segment variants (docs/SEGMENT_AWARE_CODE.md §5.4): the planner's + # direct-edge closure per segment, the segment view and the variant's + # codegen config. + add_executable(segment_variants_test + tests/segment_variants_test.cpp + src/segment_variants.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(segment_variants_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(segment_variants_test PRIVATE fmt rabbitizer) + add_test(NAME segment_variants_test COMMAND segment_variants_test) + add_executable(recompiler_patch_cxx17_header_test tests/recompiler_patch_cxx17_header_test.cpp ) diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index b5cbb8df..b79d369d 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -308,7 +308,14 @@ public: // and forward declarations for every recompiled function plus every // overlapping-alias shared body. Byte-for-byte the same declaration set // the monolith emits inline, just hoisted into a header. - std::string build_shared_decls_header(const std::vector& gen_funcs) const; + // `extra_decls` adds the alias-body prototypes of other generators whose + // functions share these shards (segment variants, §5.4). + std::string build_shared_decls_header(const std::vector& gen_funcs, + const std::vector& extra_decls = {}) const; + + // Prototypes of the psx_alias_body_XXXXXXXX() bodies the most recent + // generate_all_functions() emitted. + const std::vector& alias_body_decls() const { return alias_body_decls_; } // Generate the per-function code-range manifest consumed by the overlay // loader's per-entry validity hash (design §8). For each function, emits its @@ -325,6 +332,12 @@ public: // generate_file() call, including functions synthesized by its split pass. const std::string& last_ranges_manifest() const { return last_ranges_manifest_; } + // The final function set and CFGs the most recent generate_file() call + // emitted, after its split pass: what a segment variant's direct-edge + // closure is planned over (docs/SEGMENT_AWARE_CODE.md §5.4). + const std::vector& last_functions() const { return last_functions_; } + const std::map& last_cfgs() const { return last_cfgs_; } + // Set known functions for this compilation unit (for linking) void set_known_functions(const std::set& functions) { known_functions_ = functions; @@ -352,6 +365,15 @@ public: uint32_t runtime_pc(uint32_t compile_addr) const { return code_seg_ | (compile_addr & kPhysMask); } + // A jump-table case whose value (the PC the jr reaches) names the same + // bytes as `image_addr` in another segment than this body's. Such a case + // is not a local edge: the body bakes its own segment's PCs, so the value + // is dispatched with its full PC instead (§5.4). Never true when the table + // holds this body's own segment, which is every title's home compile. + bool foreign_segment_case(uint32_t case_value, uint32_t image_addr) const { + return (case_value & kPhysMask) == (image_addr & kPhysMask) && + case_value != runtime_pc(image_addr); + } private: const PS1Executable& exe_; @@ -359,6 +381,8 @@ private: uint32_t code_seg_; // set from the image in the constructor; see set_code_segment() std::set known_functions_; // Addresses of functions in this compilation unit std::string last_ranges_manifest_; + std::vector last_functions_; + std::map last_cfgs_; std::set extra_labels_; // Mid-block addresses that need inline labels (jump table targets) const AnnotationTable* annotations_ = nullptr; diff --git a/recompiler/include/ps1_exe_parser.h b/recompiler/include/ps1_exe_parser.h index 7aa253b0..a8af12c9 100644 --- a/recompiler/include/ps1_exe_parser.h +++ b/recompiler/include/ps1_exe_parser.h @@ -18,6 +18,16 @@ inline constexpr uint32_t kSegKUSEG = 0x00000000u; inline constexpr uint32_t kSegKSEG0 = 0x80000000u; inline constexpr uint32_t kSegKSEG1 = 0xA0000000u; +// True when `pc` lies in one of the three windows that map the physical +// address space: KUSEG below 0x20000000, KSEG0 and KSEG1. The rest of KUSEG +// (0x20000000-0x7FFFFFFF) and KSEG2 are not mirrors of it: Beetle's addr_mask +// leaves them unmasked, so they address no RAM or BIOS ROM, and a PC there +// never names the bytes at `pc & kPhysMask`. +inline constexpr bool maps_physical(uint32_t pc) { + return pc < 0x20000000u || (pc & kSegmentMask) == kSegKSEG0 || + (pc & kSegmentMask) == kSegKSEG1; +} + // PS-X EXE header structure (2048 bytes) #pragma pack(push, 1) struct PS1ExeHeader { diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 8eff90b7..5a734fbf 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2449,6 +2449,13 @@ std::string CodeGenerator::translate_basic_block( std::vector> targets; // {runtime_addr, rom_addr} for (const auto& [runtime_addr, rom_addr] : exact_table.targets) { + // A case value is the PC the jr reaches. A value + // in another segment than this body's reaches the + // same bytes as another code identity (§5.4): it + // is left to the default tail-transfer, which + // dispatches it with its full PC, instead of + // continuing here with this segment's PCs. + if (foreign_segment_case(runtime_addr, rom_addr)) continue; if (seen.insert(rom_addr).second && (cfg.blocks.count(rom_addr) || extra_labels_.count(rom_addr))) { targets.push_back({runtime_addr, rom_addr}); @@ -3042,6 +3049,9 @@ void CodeGenerator::scan_jr_tables( if (tb==0 || tc==0) continue; for (const auto& target_pair : exact_table.targets) { uint32_t t = target_pair.second; + // Not a local edge: dispatched with its own segment (see the + // switch emission in translate_basic_block). + if (foreign_segment_case(target_pair.first, t)) continue; if (t >= cfg.function_start && t < cfg.function_end) { out_edges[baddr].push_back(t); if (!cfg.blocks.count(t)) @@ -3537,7 +3547,8 @@ void CodeGenerator::emit_unaligned_helpers(std::ostream& ss, bool as_inline) con ss << "\n"; } -std::string CodeGenerator::build_shared_decls_header(const std::vector& gen_funcs) const { +std::string CodeGenerator::build_shared_decls_header(const std::vector& gen_funcs, + const std::vector& extra_decls) const { std::stringstream h; h << "/* Generated by PSXRecomp - shared declarations for split full.c shards. DO NOT EDIT. */\n"; h << "#pragma once\n"; @@ -3548,6 +3559,7 @@ std::string CodeGenerator::build_shared_decls_header(const std::vector{{&codegen, dispatch_addrs}}, + exe, ranges_manifest, out, error); +} + +bool emit_game_dispatch(const std::vector& units, + const PS1Executable& exe, + const std::string& ranges_manifest, + std::string& out, + std::string& error) { + if (units.empty() || !units.front().codegen) { + error = "emit_game_dispatch: no compile to dispatch"; + return false; + } + const bool cps = units.front().codegen->cps_enabled(); std::ostringstream ds; ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; ds << "#include \"psx_runtime.h\"\n\n"; @@ -25,8 +39,10 @@ bool emit_game_dispatch(const CodeGenerator& codegen, // Forward declarations ds << "/* Forward declarations for all recompiled functions */\n"; - for (uint32_t addr : dispatch_addrs) { - ds << fmt::format("extern void func_{:08X}(CPUState* cpu);\n", addr); + for (const auto& unit : units) { + for (uint32_t addr : unit.dispatch_addrs) { + ds << fmt::format("extern void func_{:08X}(CPUState* cpu);\n", addr); + } } ds << "\n"; @@ -46,45 +62,57 @@ bool emit_game_dispatch(const CodeGenerator& codegen, // GCC lowers X4's roughly 60K cases to a multi-megabyte linear compare // chain. Binary search stays O(log N) at every optimizer level and // avoids emitting the same case set twice. + // `key` and `resume` are the PCs the guest executes, routed through the + // unit's runtime_pc() (docs/SEGMENT_AWARE_CODE.md §5.2); `addr` and + // `owner` are compile identities (func_ names, .ranges owners). resume 0 + // means "entry at the prologue", not a PC. struct DispatchRecord { uint32_t addr; + uint32_t key; uint32_t resume; uint32_t owner; uint32_t range_index; uint32_t range_count; }; std::vector records; - records.reserve(dispatch_addrs.size() + - (codegen.cps_enabled() ? codegen.cps_continuations().size() : 0)); - for (uint32_t addr : dispatch_addrs) - records.push_back({addr, 0, addr, 0, 0}); - if (codegen.cps_enabled()) { - const auto& conts = codegen.cps_continuations(); - for (const auto& [cont, owner] : conts) { - if (dispatch_addrs.count(cont)) continue; - // A continuation owned by a fail-closed stub is no more - // executable than the stub entry itself. - if (!dispatch_addrs.count(owner)) continue; - records.push_back({cont, cont, owner, 0, 0}); + for (const auto& unit : units) { + const CodeGenerator& codegen = *unit.codegen; + for (uint32_t addr : unit.dispatch_addrs) + records.push_back({addr, codegen.runtime_pc(addr), 0, addr, 0, 0}); + if (cps) { + const auto& conts = codegen.cps_continuations(); + for (const auto& [cont, owner] : conts) { + if (unit.dispatch_addrs.count(cont)) continue; + // A continuation owned by a fail-closed stub is no more + // executable than the stub entry itself. + if (!unit.dispatch_addrs.count(owner)) continue; + records.push_back({cont, codegen.runtime_pc(cont), codegen.runtime_pc(cont), + owner, 0, 0}); + } } } - // Sort by PHYSICAL address: the lookup indexes (or binary-searches) the - // physical word and then compares the full PC. One compile has one code - // segment, so the physical order is the raw order and every physical word - // has at most one row; a second row for a word would be a variant of - // another segment (§5.4), which this table does not hold yet. + // Sort by PHYSICAL word, then by the full PC: the lookup indexes (or + // binary-searches) the physical word and then requires the exact PC. A + // word has one row per segment compiled for it, adjacent: the home + // compile's and those of its segment variants (§5.4). Within one compile + // the code segment is fixed, so two rows on one word are the same PC. std::sort(records.begin(), records.end(), [](const DispatchRecord& a, const DispatchRecord& b) { - return (a.addr & 0x1FFFFFFFu) < (b.addr & 0x1FFFFFFFu); + const uint32_t pa = a.key & 0x1FFFFFFFu, pb = b.key & 0x1FFFFFFFu; + return pa != pb ? pa < pb : a.key < b.key; }); + bool shared_words = false; for (size_t i = 1; i < records.size(); ++i) { - if ((records[i].addr & 0x1FFFFFFFu) == (records[i - 1].addr & 0x1FFFFFFFu)) { + if (records[i].key == records[i - 1].key) { error = fmt::format( - "dispatch rows 0x{:08X} and 0x{:08X} share a physical word; one " - "compile has one code segment (docs/SEGMENT_AWARE_CODE.md §5.5)", - records[i - 1].addr, records[i].addr); + "dispatch rows 0x{:08X} and 0x{:08X} are the same PC 0x{:08X}: they " + "share a physical word in one code segment " + "(docs/SEGMENT_AWARE_CODE.md §5.5)", + records[i - 1].addr, records[i].addr, records[i].key); return false; } + if ((records[i].key & 0x1FFFFFFFu) == (records[i - 1].key & 0x1FFFFFFFu)) + shared_words = true; } // Attach the exact CFG instruction ranges from the manifest to every @@ -159,15 +187,10 @@ bool emit_game_dispatch(const CodeGenerator& codegen, ds << " PsxGameDispatchFn fn;\n"; ds << "} PsxGameDispatchEntry;\n\n"; ds << "static const PsxGameDispatchEntry k_psx_game_dispatch[] = {\n"; - // Row keys and resume PCs are the PCs the guest executes, so they go - // through the emitter's runtime_pc() (docs/SEGMENT_AWARE_CODE.md - // §5.2); func_ names stay the compile identity. resume 0 means "entry - // at the prologue", not a PC. for (const auto& rec : records) { ds << fmt::format(" {{0x{:08X}u, 0x{:08X}u, {}u, {}u, func_{:08X}}},\n", - codegen.runtime_pc(rec.addr), - rec.resume ? codegen.runtime_pc(rec.resume) : 0u, - rec.range_index, rec.range_count, rec.owner); + rec.key, rec.resume, rec.range_index, rec.range_count, + rec.owner); } ds << "};\n"; ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); @@ -177,16 +200,17 @@ bool emit_game_dispatch(const CodeGenerator& codegen, // callers below still check live instruction ranges on every dispatch. // Bound the indexed span to one PS1 RAM image; wider tables retain the // binary search. No mutable cache or flag. - const uint32_t lookup_lo = records.empty() ? 0u : (records.front().addr & 0x1FFFFFFFu); - const uint32_t lookup_hi = records.empty() ? 0u : (records.back().addr & 0x1FFFFFFFu); + const uint32_t lookup_lo = records.empty() ? 0u : (records.front().key & 0x1FFFFFFFu); + const uint32_t lookup_hi = records.empty() ? 0u : (records.back().key & 0x1FFFFFFFu); bool indexed_lookup = !records.empty() && lookup_hi - lookup_lo < 0x200000u; for (size_t i = 0; i < records.size(); ++i) { - if (records[i].addr & 3u) indexed_lookup = false; + if (records[i].key & 3u) indexed_lookup = false; } if (indexed_lookup) { + // Each word indexes its FIRST row; a word's other segments follow it. std::vector index((lookup_hi - lookup_lo) / 4u + 1u, 0u); - for (size_t i = 0; i < records.size(); ++i) - index[((records[i].addr & 0x1FFFFFFFu) - lookup_lo) / 4u] = (uint32_t)i + 1u; + for (size_t i = records.size(); i-- > 0;) + index[((records[i].key & 0x1FFFFFFFu) - lookup_lo) / 4u] = (uint32_t)i + 1u; ds << "/* Immutable physical-word index; zero denotes a dispatch miss. */\n"; ds << "static const " << (records.size() <= 65535u ? "uint16_t" : "uint32_t") << " k_psx_game_dispatch_index[] = {\n"; @@ -206,7 +230,30 @@ bool emit_game_dispatch(const CodeGenerator& codegen, ds << " * interprets it and records it, and never runs this body for it. */\n"; ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n"; ds << " const uint32_t want = addr & 0x1FFFFFFFu;\n"; - if (indexed_lookup) { + if (shared_words) { + // Segment variants (§5.4): a word may have a row per compiled + // segment, adjacent and in PC order. Find the word's first row, then + // the exact PC among its rows. + if (indexed_lookup) { + ds << fmt::format(" const uint32_t offset = want - 0x{:08X}u;\n", lookup_lo); + ds << fmt::format(" if ((want & 3u) || offset > 0x{:X}u) return 0;\n", lookup_hi - lookup_lo); + ds << " const uint32_t index = k_psx_game_dispatch_index[offset >> 2];\n"; + ds << " if (!index) return 0;\n"; + ds << " uint32_t row = index - 1u;\n"; + } else { + ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; + ds << " while (lo < hi) { /* first row whose word is >= want */\n"; + ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; + ds << " if ((k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu) < want) lo = mid + 1;\n"; + ds << " else hi = mid;\n"; + ds << " }\n"; + ds << " uint32_t row = lo;\n"; + } + ds << " for (; row < PSX_GAME_DISPATCH_COUNT &&\n"; + ds << " (k_psx_game_dispatch[row].addr & 0x1FFFFFFFu) == want; ++row)\n"; + ds << " if (k_psx_game_dispatch[row].addr == addr) return &k_psx_game_dispatch[row];\n"; + ds << " return 0;\n"; + } else if (indexed_lookup) { ds << fmt::format(" const uint32_t offset = want - 0x{:08X}u;\n", lookup_lo); ds << fmt::format(" if ((want & 3u) || offset > 0x{:X}u) return 0;\n", lookup_hi - lookup_lo); ds << " const uint32_t index = k_psx_game_dispatch_index[offset >> 2];\n"; @@ -248,7 +295,7 @@ bool emit_game_dispatch(const CodeGenerator& codegen, ds << " if (entry->range_count == 0 || !dirty_ram_text_native_ok_ranges_from(\n"; ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr)) return 0;\n"; ds << " psx_check_interrupts_dispatch_entry(cpu, addr);\n"; - if (codegen.cps_enabled()) + if (cps) ds << " cpu->pc = entry->resume_pc;\n"; ds << " /* load_accel: cycle-faithful VSync(-1) query (config-gated). */\n"; ds << " {\n"; @@ -274,7 +321,7 @@ bool emit_game_dispatch(const CodeGenerator& codegen, ds << " return psx_game_find_entry(addr) != 0;\n"; ds << "}\n"; - if (codegen.cps_enabled()) { + if (cps) { // RECURSION_BUG.md §25 — mark CPS mode at startup so runtime code that // routes CPS continuations (overlay_loader.c) sees the contract. // Static ctor: no clash with the BIOS dispatch's marker. diff --git a/recompiler/src/game_dispatch_emitter.h b/recompiler/src/game_dispatch_emitter.h index 6f48fc87..25f84e6b 100644 --- a/recompiler/src/game_dispatch_emitter.h +++ b/recompiler/src/game_dispatch_emitter.h @@ -17,21 +17,39 @@ #include #include #include +#include #include "code_generator.h" #include "ps1_exe_parser.h" namespace PSXRecomp { -// Emits the dispatch source for `dispatch_addrs` (compile addresses of the -// dispatchable func_ entries) and the generator's CPS continuations. Row keys -// and resume PCs go through codegen.runtime_pc() (§5.2); func_ names keep the -// compile identity. `ranges_manifest` is the emitted .ranges text, whose F/R -// records give each row its exact instruction ranges. +// One compile whose bodies the table dispatches: the home compile, or a +// segment variant (§5.4). `dispatch_addrs` are the compile addresses of its +// dispatchable func_ entries; its CPS continuations come from the generator. +struct GameDispatchUnit { + const CodeGenerator* codegen; + std::set dispatch_addrs; +}; + +// Emits the dispatch source for every unit's entries and CPS continuations. +// Row keys and resume PCs go through the unit's runtime_pc() (§5.2); func_ +// names keep the compile identity. `ranges_manifest` is the emitted .ranges +// text of all units, whose F/R records give each row its exact instruction +// ranges. Rows are sorted by physical word; one word may carry a row per +// compiled segment (the home body and its variants), and the lookup requires +// the exact PC. The first unit's CPS mode applies to the table. // -// Returns false with `error` set when two rows would share a physical word: -// one compile has one code segment, so that can only be a caller error until -// per-segment variants (§5.4) share the table. +// Returns false with `error` set when two rows name the same PC. Within one +// compile that means two rows on one physical word: one compile has one code +// segment. +bool emit_game_dispatch(const std::vector& units, + const PS1Executable& exe, + const std::string& ranges_manifest, + std::string& out, + std::string& error); + +// The single-compile form (no segment variants). bool emit_game_dispatch(const CodeGenerator& codegen, const PS1Executable& exe, const std::set& dispatch_addrs, diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 446650b1..b1500880 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include "control_flow.h" #include "code_generator.h" #include "game_dispatch_emitter.h" +#include "segment_variants.h" #include "annotations.hpp" #include "config_loader.h" #include "rabbitizer.hpp" @@ -773,10 +775,10 @@ static int psxrecomp_game_main(int argc, char** argv) { * §5.3, §5.4). The bounds are checked by physical address: a seed in the * image's link segment (0x000100E4 for a KUSEG-linked EXE, 0x800100E4 * for a KSEG0 one) is an entry of the image. A seed for the same bytes in - * another segment asks for a segment variant (§5.4), which is not - * compiled yet: it is listed below and never folded into the home body, - * so that PC stays a loud segment miss at run time. Seeds outside the - * image are counted. */ + * another segment asks for a segment variant (§5.4): it is never folded + * into the home body; after the home compile, the home functions it + * reaches through direct edges are compiled again in its segment (see + * plan_segment_variants below). Seeds outside the image are counted. */ std::vector file_seeds; std::vector> variant_seeds; // PC, line kind size_t outside_seeds = 0; @@ -942,6 +944,20 @@ static int psxrecomp_game_main(int argc, char** argv) { if (phys < (seed_lo & PSXRecomp::kPhysMask) || phys >= (seed_lo & PSXRecomp::kPhysMask) + (seed_hi - seed_lo)) { ++outside_seeds; + } else if (!PSXRecomp::maps_physical(addr)) { + // Only KUSEG below 0x20000000, KSEG0 and KSEG1 alias the + // image's bytes; a variant in any other segment would + // give dispatch rows to PCs hardware cannot fetch as RAM. + fmt::print(stderr, + "ERROR: seed 0x{:08X} is in segment 0x{:08X}, which does not " + "map physical memory (only KUSEG below 0x20000000, KSEG0 and " + "KSEG1 do), so it names no code of this image and cannot ask " + "for a segment variant " + "(docs/SEGMENT_AWARE_CODE.md §5.4). The image's bytes at that " + "physical address are 0x{:08X} in its link segment.\n", + addr, addr & PSXRecomp::kSegmentMask, + exe->link_segment() | phys); + return 1; } else if ((addr & PSXRecomp::kSegmentMask) != exe->link_segment()) { variant_seeds.emplace_back( addr, interior ? "interior" : trusted_root ? "dispatch_root" @@ -998,13 +1014,20 @@ static int psxrecomp_game_main(int argc, char** argv) { : seg == PSXRecomp::kSegKSEG0 ? "KSEG0" : seg == PSXRecomp::kSegKSEG1 ? "KSEG1" : "an unmapped segment"; }; - fmt::print("WARNING: {} seed(s) name this image's bytes in another segment " - "than its link segment {}. Each asks for a segment variant " - "(docs/SEGMENT_AWARE_CODE.md §5.4), which is not compiled " - "yet: that PC runs interpreted and is recorded as a segment " - "miss. A seed for the image's own code is written in {}.\n", - variant_seeds.size(), seg_name(exe->link_segment()), - seg_name(exe->link_segment())); + if (overlay_mode) { + fmt::print("WARNING: {} seed(s) name this image's bytes in another " + "segment than its link segment {}. Overlay compiles have " + "no segment variants yet (docs/SEGMENT_AWARE_CODE.md §5.7): " + "nothing is compiled for them.\n", + variant_seeds.size(), seg_name(exe->link_segment())); + } else { + fmt::print("{} seed(s) name this image's bytes in another segment than " + "its link segment {}. Each asks for a segment variant " + "(docs/SEGMENT_AWARE_CODE.md §5.4), compiled after the home " + "compile. A seed for the image's own code is written in {}.\n", + variant_seeds.size(), seg_name(exe->link_segment()), + seg_name(exe->link_segment())); + } for (const auto& [addr, kind] : variant_seeds) { fmt::print(" variant seed 0x{:08X} ({}, {}): home PC would be 0x{:08X}\n", addr, kind, seg_name(addr & PSXRecomp::kSegmentMask), @@ -1563,6 +1586,70 @@ static int psxrecomp_game_main(int argc, char** argv) { } } + // Segment variants (docs/SEGMENT_AWARE_CODE.md §5.4). Each segment named + // by a variant seed gets the direct-edge closure of the home functions + // its seeds reach, compiled again through a view of the image in that + // segment: its names, rows and every PC it bakes are that segment's, and + // a KSEG1 variant charges a fetch per instruction (§5.6). The bodies join + // the home shards, and their rows join the home dispatch table. Overlay + // compiles have none yet (§5.7). + std::vector all_gen_funcs = codegen.last_gen_funcs(); + std::vector variant_alias_decls; + std::string all_ranges_manifest = codegen.last_ranges_manifest().empty() + ? codegen.generate_ranges_manifest(analysis_result.functions, all_cfgs) + : codegen.last_ranges_manifest(); + std::vector dispatch_units{{&codegen, dispatch_addrs}}; + std::vector> variant_views; + std::vector> variant_codegens; + if (!overlay_mode && !variant_seeds.empty()) { + std::vector requested; + for (const auto& [addr, kind] : variant_seeds) { + (void)kind; + requested.push_back(addr); + } + const auto plans = PSXRecomp::plan_segment_variants( + *exe, codegen.last_functions(), codegen.last_cfgs(), + codegen.cps_continuations(), requested); + for (const auto& plan : plans) { + fmt::print("\n=== Segment variant 0x{:08X} (docs/SEGMENT_AWARE_CODE.md §5.4) ===\n", + plan.segment); + for (const auto& [pc, why] : plan.unplaced) { + fmt::print(stderr, + "ERROR: variant seed 0x{:08X} cannot be compiled: {}. Seed the home " + "PC first, or drop the variant seed.\n", pc, why); + } + if (!plan.unplaced.empty()) return 1; + fmt::print(" {} seed(s), direct-edge closure of {} function(s)\n", + plan.seeds.size(), plan.functions.size()); + variant_views.push_back(std::make_unique( + PSXRecomp::segment_view(*exe, plan.segment))); + const PSXRecomp::PS1Executable& view = *variant_views.back(); + PSXRecomp::ControlFlowAnalyzer view_cfg(view); + const auto variant_cfgs = view_cfg.analyze_all_functions(plan.functions); + variant_codegens.push_back(std::make_unique( + view, PSXRecomp::rebase_codegen_config(codegen_config, *exe, plan.segment))); + PSXRecomp::CodeGenerator& vgen = *variant_codegens.back(); + (void)vgen.generate_file(plan.functions, variant_cfgs); + std::set variant_addrs; + for (const auto& gf : vgen.last_gen_funcs()) { + all_gen_funcs.push_back(gf); + if (gf.dispatchable && gf.function_name.rfind("func_", 0) == 0 && + gf.function_name.size() == 13) + variant_addrs.insert(static_cast( + std::strtoul(gf.function_name.c_str() + 5, nullptr, 16))); + } + for (const auto& decl : vgen.alias_body_decls()) variant_alias_decls.push_back(decl); + // The variant's F/R records, without the manifest's header comment. + std::istringstream vr(vgen.last_ranges_manifest()); + for (std::string line; std::getline(vr, line);) { + if (!line.empty() && line[0] != '#') all_ranges_manifest += line + "\n"; + } + dispatch_units.push_back({&vgen, variant_addrs}); + fmt::print(" {} bodies, {} dispatch entries\n", + vgen.last_gen_funcs().size(), variant_addrs.size()); + } + } + // Split-TU output: instead of one monolithic _full.c, write a // shared declarations header plus N shards bucketed by cumulative line // count, so the game build can compile the shards in parallel. The @@ -1587,7 +1674,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::filesystem::path decls_filename = out_dir / (exe_stem + "_decls.h"); { const std::string decls = - codegen.build_shared_decls_header(codegen.last_gen_funcs()); + codegen.build_shared_decls_header(all_gen_funcs, variant_alias_decls); if (write_file_if_changed(decls_filename, decls)) { fmt::print("✓ Saved shared decls header to {}\n", decls_filename.string()); } else { @@ -1597,7 +1684,7 @@ static int psxrecomp_game_main(int argc, char** argv) { // 2. Bucket generated functions into shards by cumulative line count. const int SHARD_LINE_BUDGET = 40000; - const std::vector& gen_funcs_for_shards = codegen.last_gen_funcs(); + const std::vector& gen_funcs_for_shards = all_gen_funcs; std::string decls_basename = exe_stem + "_decls.h"; int shard_index = 0; @@ -1654,9 +1741,7 @@ static int psxrecomp_game_main(int argc, char** argv) { // Per-function code-range manifest (design §8): consumed by the overlay // loader's per-entry validity hash. Emitted alongside _full.c for every // build; only the overlay path actually loads it. - const std::string ranges_manifest = codegen.last_ranges_manifest().empty() - ? codegen.generate_ranges_manifest(analysis_result.functions, all_cfgs) - : codegen.last_ranges_manifest(); + const std::string& ranges_manifest = all_ranges_manifest; { std::filesystem::path ranges_filename = out_dir / (exe_stem + "_full.ranges"); if (write_file_if_changed(ranges_filename, ranges_manifest)) { @@ -1676,18 +1761,24 @@ static int psxrecomp_game_main(int argc, char** argv) { fmt::print("Generating dispatch table: {}\n", dispatch_filename.string()); std::string dispatch_source, dispatch_error; - if (!PSXRecomp::emit_game_dispatch(codegen, *exe, dispatch_addrs, + if (!PSXRecomp::emit_game_dispatch(dispatch_units, *exe, ranges_manifest, dispatch_source, dispatch_error)) { fmt::print(stderr, "ERROR: {}\n", dispatch_error); return 1; } + // Entries of the home compile, plus those of its segment variants. + size_t variant_entries = 0; + for (size_t u = 1; u < dispatch_units.size(); ++u) + variant_entries += dispatch_units[u].dispatch_addrs.size(); + const std::string variant_note = variant_entries + ? fmt::format(", plus {} in segment variants", variant_entries) : std::string(); if (write_file_if_changed(dispatch_filename, dispatch_source)) { - fmt::print("✓ Dispatch table written ({} entries)\n\n", - dispatch_addrs.size()); + fmt::print("✓ Dispatch table written ({} entries{})\n\n", + dispatch_addrs.size(), variant_note); } else { - fmt::print("✓ Dispatch table unchanged ({} entries)\n\n", - dispatch_addrs.size()); + fmt::print("✓ Dispatch table unchanged ({} entries{})\n\n", + dispatch_addrs.size(), variant_note); } } diff --git a/recompiler/src/segment_variants.cpp b/recompiler/src/segment_variants.cpp new file mode 100644 index 00000000..e000932b --- /dev/null +++ b/recompiler/src/segment_variants.cpp @@ -0,0 +1,202 @@ +#include "segment_variants.h" + +#include +#include + +#include "fmt/format.h" + +namespace PSXRecomp { + +PS1Executable segment_view(const PS1Executable& exe, uint32_t seg) { + PS1Executable view = exe; + seg &= kSegmentMask; + view.header.load_address = seg | (exe.header.load_address & kPhysMask); + view.header.initial_pc = seg | (exe.header.initial_pc & kPhysMask); + return view; +} + +std::vector direct_successors(const ControlFlowGraph& cfg) { + std::set out; + auto leave = [&](uint32_t target) { + if (target != 0 && !cfg.blocks.count(target)) out.insert(target); + }; + for (const auto& [addr, block] : cfg.blocks) { + (void)addr; + const ControlFlowInstr& x = block.exit_instr; + switch (x.type) { + case ControlFlowType::Branch: + leave(x.target); + leave(x.address + 8u); + break; + case ControlFlowType::Jump: + leave(x.target); + break; + case ControlFlowType::JumpLink: + leave(x.target); + leave(x.address + 8u); + break; + case ControlFlowType::JumpLinkReg: + leave(x.address + 8u); + break; + case ControlFlowType::None: + if (block.successors.empty()) leave(block.end_addr + 4u); + break; + default: // jr / jr $ra: indirect, through dispatch + break; + } + } + return {out.begin(), out.end()}; +} + +namespace { + +Function rebase_function(const Function& f, uint32_t seg) { + auto at = [seg](uint32_t a) { return a ? (seg | (a & kPhysMask)) : 0u; }; + Function v = f; + v.start_addr = at(f.start_addr); + v.end_addr = seg | (f.end_addr & kPhysMask); + v.name = fmt::format("func_{:08X}", v.start_addr); + v.alias_walk_lo = at(f.alias_walk_lo); + for (uint32_t& e : v.alias_group_entries) e = at(e); + v.producer_lo = at(f.producer_lo); + v.producer_hi = f.producer_hi ? (seg | (f.producer_hi & kPhysMask)) : 0u; + return v; +} + +} // namespace + +std::vector plan_segment_variants( + const PS1Executable& exe, + const std::vector& home_functions, + const std::map& home_cfgs, + const std::map& home_continuations, + const std::vector& seeds) { + const uint32_t link = exe.link_segment(); + std::map by_start; + std::map> alias_groups; // host -> members + for (const Function& f : home_functions) { + by_start.emplace(f.start_addr, &f); + if (f.alias_walk_lo != 0) alias_groups[f.alias_walk_lo].push_back(f.start_addr); + } + + std::map plans; + std::map> roots; + for (uint32_t pc : seeds) { + const uint32_t seg = pc & kSegmentMask; + SegmentVariantPlan& plan = plans[seg]; + plan.segment = seg; + plan.seeds.push_back(pc); + const uint32_t home = link | (pc & kPhysMask); + if (!maps_physical(pc)) { + plan.unplaced.emplace_back(pc, fmt::format( + "segment 0x{:08X} does not map physical memory (only KUSEG below " + "0x20000000, KSEG0 and KSEG1 do), so it is no alias of the home PC " + "0x{:08X}", seg, home)); + continue; + } + uint32_t root = 0; + if (by_start.count(home)) { + root = home; + } else { + auto cont = home_continuations.find(home); + if (cont != home_continuations.end() && by_start.count(cont->second)) + root = cont->second; + } + if (root == 0) { + plan.unplaced.emplace_back(pc, fmt::format( + "its home PC 0x{:08X} is not a dispatch entry of the home compile " + "(no function starts there and it is no CPS continuation)", home)); + continue; + } + roots[seg].insert(root); + } + + std::vector out; + for (auto& [seg, plan] : plans) { + std::set included; + std::vector work(roots[seg].begin(), roots[seg].end()); + while (!work.empty()) { + const uint32_t start = work.back(); + work.pop_back(); + if (!included.insert(start).second) continue; + const Function* f = by_start.at(start); + // Alias entries share one emitted body with their host's other + // entries: compile the group together. + if (f->alias_walk_lo != 0) { + for (uint32_t member : alias_groups[f->alias_walk_lo]) work.push_back(member); + } + auto cfg = home_cfgs.find(start); + if (cfg == home_cfgs.end()) continue; + for (uint32_t succ : direct_successors(cfg->second)) { + if (by_start.count(succ)) work.push_back(succ); + } + } + for (uint32_t start : included) plan.functions.push_back(rebase_function(*by_start.at(start), seg)); + std::sort(plan.functions.begin(), plan.functions.end(), + [](const Function& a, const Function& b) { return a.start_addr < b.start_addr; }); + out.push_back(std::move(plan)); + } + return out; +} + +CodeGenConfig rebase_codegen_config(const CodeGenConfig& cfg, + const PS1Executable& exe, uint32_t seg) { + seg &= kSegmentMask; + const uint32_t link = exe.link_segment(); + auto at = [&](uint32_t a) { + return (a != 0 && exe.contains_phys(a) && (a & kSegmentMask) == link) + ? (seg | (a & kPhysMask)) : a; + }; + auto move_set = [&](std::set& s) { + std::set moved; + for (uint32_t a : s) moved.insert(at(a)); + s = std::move(moved); + }; + CodeGenConfig v = cfg; + move_set(v.ws_sprite_tag_funcs); + move_set(v.data_shard_funcs); + move_set(v.mod_function_entry_funcs); + move_set(v.hot_funcs); + move_set(v.load_charge_batch_funcs); + move_set(v.ws_backdrop_unsquash_funcs); + move_set(v.ws_cull_bias_sites); + move_set(v.ws_cull_range_sites); + move_set(v.ws_cull_a1_sites); + move_set(v.ws_cull_screen_x_sites); + move_set(v.ws_cull_slti_sites); + move_set(v.ws_cull_slti_lower_sites); + move_set(v.ws_cull_bltz_sites); + move_set(v.ws_cull_bgez_sites); + move_set(v.ws_cull_clip_edge_x_load_sites); + move_set(v.ws_cull_negsub_sites); + move_set(v.ws_cull_vxrange_sites); + move_set(v.ws_cull_depth_sites); + move_set(v.ws_cull_plane_nx_sites); + move_set(v.ws_cull_xclip_load_sites); + move_set(v.ws_cull_nclip_keep_sites); + move_set(v.ws_cull_nclip_exact_sites); + move_set(v.ws_cull_branch_keep_sites); + move_set(v.ws_backdrop_x_sites); + move_set(v.persist_init_store_sites); + { + std::map> moved; + for (const auto& [func, data] : v.vsync_query_hle_funcs) moved[at(func)] = data; + v.vsync_query_hle_funcs = std::move(moved); + } + v.ws_bg2d_init_func = at(v.ws_bg2d_init_func); + v.ws_bg2d_count_site = at(v.ws_bg2d_count_site); + v.ws_bg2d_startcol_site = at(v.ws_bg2d_startcol_site); + v.ws_bg2d_startx_site = at(v.ws_bg2d_startx_site); + v.ws_bg2d_stream_left_site = at(v.ws_bg2d_stream_left_site); + v.ws_bg2d_stream_right_site = at(v.ws_bg2d_stream_right_site); + v.ws_bg2d_bufbase_site = at(v.ws_bg2d_bufbase_site); + v.ws_bg2d_cap_site = at(v.ws_bg2d_cap_site); + // Physically matched, kept as written: ws_signed_x_bound_sites, + // ws_cull_keep_sites, ws_cull_angle_sites, ws_aspect_cone.sites. + // The split pre-pass ran on the home compile: the closure is already made + // of its final pieces. + v.split_mid_function_targets = false; + return v; +} + +} // namespace PSXRecomp diff --git a/recompiler/src/segment_variants.h b/recompiler/src/segment_variants.h new file mode 100644 index 00000000..8d874b79 --- /dev/null +++ b/recompiler/src/segment_variants.h @@ -0,0 +1,80 @@ +#pragma once + +// Per-segment compiled variants of static game code +// (docs/SEGMENT_AWARE_CODE.md §5.4). +// +// A PC carries a segment, and a compiled body bakes the segment it was +// compiled for into every link, EPC, fetch tag and store PC. The home compile +// is the image's link segment. A seed that names the image's bytes in another +// segment (a segment-qualified seed, §10 decision 3) asks for a variant: the +// home functions that seed reaches through direct edges (calls, tail jumps, +// split pieces, fallthroughs, CPS continuations), compiled again in that +// segment. Direct control flow never changes segment (§2), so that closure is +// everything the variant can reach without going through dispatch. +// +// A variant is compiled through a segment view of the image: the same bytes +// with the header moved into the variant's segment. Its code identity is then +// the variant VA (§5.1: func_A00100F0), the CodeGenerator's code segment is the +// view's, and every PC it bakes is in that segment. A KSEG1 variant is charged +// a fetch per instruction by #429's rule (§5.6) with no emitter change. + +#include +#include +#include +#include +#include + +#include "code_generator.h" +#include "control_flow.h" +#include "function_analysis.h" +#include "ps1_exe_parser.h" + +namespace PSXRecomp { + +// The image seen from segment `seg`: the header's load address and entry move +// into `seg`, the bytes do not change. Addresses in the view are variant VAs. +PS1Executable segment_view(const PS1Executable& exe, uint32_t seg); + +// One requested segment. +struct SegmentVariantPlan { + uint32_t segment = 0; + // The closure, rebased into `segment` and sorted by start address. + std::vector functions; + // The requested PCs, as written. + std::vector seeds; + // Seeds that name no home dispatch entry (a function entry or a CPS + // continuation of the home compile), or that lie in a segment that does + // not map physical memory (maps_physical()), with the reason. Nothing is + // compiled for them. + std::vector> unplaced; +}; + +// The direct-edge successors of a home function that leave it: branch and +// jump targets and not-taken paths outside its blocks, call targets, call +// returns split into another piece, and a fall-through past its last block. +// Indirect transfers are not edges: they go through dispatch with the full PC. +std::vector direct_successors(const ControlFlowGraph& cfg); + +// Plans a variant per segment named by `seeds` (PCs outside the link segment +// that name the image's bytes). `home_functions` and `home_cfgs` are the home +// compile's final function set (CodeGenerator::last_functions / last_cfgs), +// and `home_continuations` its CPS continuation -> owner map. A seed at a home +// function entry roots that function; a seed at a CPS continuation roots its +// owner (the variant has the continuation too); an alias entry brings its +// whole alias group, which shares one body. Plans are in segment order. +std::vector plan_segment_variants( + const PS1Executable& exe, + const std::vector& home_functions, + const std::map& home_cfgs, + const std::map& home_continuations, + const std::vector& seeds); + +// The codegen config for a variant in `seg`. Config code sites that the +// emitter matches exactly are written in the link segment (main_psx refuses +// any other spelling, §5.3); each one that names the image's bytes moves into +// `seg`, so the variant gets every hook and substitution the home body gets. +// Kinds matched by physical address need no move and are kept as written. +CodeGenConfig rebase_codegen_config(const CodeGenConfig& cfg, + const PS1Executable& exe, uint32_t seg); + +} // namespace PSXRecomp diff --git a/recompiler/tests/emitter_runtime_pc_test.cpp b/recompiler/tests/emitter_runtime_pc_test.cpp index 14644a9e..e31025b0 100644 --- a/recompiler/tests/emitter_runtime_pc_test.cpp +++ b/recompiler/tests/emitter_runtime_pc_test.cpp @@ -39,6 +39,9 @@ // compiles in its own link segment, where runtime_pc() is the identity, so // only this test (code segment != compile segment) sees the routes; the // ledger and test_kuseg_dispatch_lookup.py check the emitted lookup end to end. +// Two rows on one physical word are an error within one compile; a segment +// variant's compile (PR D, §5.4) adds rows on its home rows' words, and the +// lookup then searches the word's rows for the exact PC. #include "code_generator.h" #include "control_flow.h" @@ -497,9 +500,11 @@ void check_dispatch_rows(Mode mode) { } } -// One compile has one code segment, so two rows on one physical word (a -// variant of another segment, §5.4) are a build error until PR D's variants -// share the table: never a row that silently shadows the other. +// One compile has one code segment, so two rows on one physical word within +// it are the same PC: a build error, never a row that silently shadows the +// other. Segment variants (§5.4) are separate compiles, and their rows share +// the home rows' words: adjacent, in PC order, looked up by exact PC. Two +// compiles in one segment would name the same PCs, which is an error again. void check_dispatch_duplicate_word() { const uint32_t base = KSEG0 | kPhys; PSXRecomp::PS1Executable exe{}; @@ -522,6 +527,37 @@ void check_dispatch_duplicate_word() { check(error.find("share a physical word") != std::string::npos, "dispatch: the duplicate-word error must say why, got: " + error); } + + PSXRecomp::PS1Executable kseg1 = exe; + kseg1.header.load_address = KSEG1 | kPhys; + kseg1.header.initial_pc = KSEG1 | kPhys; + PSXRecomp::CodeGenerator variant(kseg1, config); + const std::vector units{ + {&generator, {base, base + 0x20u}}, {&variant, {KSEG1 | kPhys}}}; + out.clear(); + error.clear(); + check(PSXRecomp::emit_game_dispatch(units, exe, "", out, error) && error.empty(), + "dispatch: a variant compile's row may share its home row's word: " + error); + const auto home_row = out.find("{0x" + hex(base) + "u, 0x00000000u"); + const auto variant_row = out.find("{0x" + hex(KSEG1 | kPhys) + "u, 0x00000000u"); + const auto variant_line = + variant_row == std::string::npos + ? std::string() + : out.substr(variant_row, out.find('\n', variant_row) - variant_row); + check(home_row != std::string::npos && variant_row != std::string::npos && + home_row < variant_row && + variant_line.find("func_" + hex(KSEG1 | kPhys) + "}") != std::string::npos, + "dispatch: the variant row follows the home row on their word and names its " + "body: " + variant_line); + check(out.find("k_psx_game_dispatch[row].addr == addr") != std::string::npos, + "dispatch: a shared word is searched for the exact PC"); + const std::vector twice{ + {&generator, {base}}, {&variant, {KSEG1 | kPhys}}, {&variant, {KSEG1 | kPhys}}}; + out.clear(); + error.clear(); + check(!PSXRecomp::emit_game_dispatch(twice, exe, "", out, error) && + error.find("are the same PC 0x" + hex(KSEG1 | kPhys)) != std::string::npos, + "dispatch: two compiles in one segment name the same PC, an error: " + error); } void check_strict_translator(bool strict_cps) { diff --git a/recompiler/tests/segment_variants_test.cpp b/recompiler/tests/segment_variants_test.cpp new file mode 100644 index 00000000..79a13c5e --- /dev/null +++ b/recompiler/tests/segment_variants_test.cpp @@ -0,0 +1,273 @@ +// Segment variants of static game code (docs/SEGMENT_AWARE_CODE.md §5.4, +// rollout PR D): the planner that turns segment-qualified seeds into per- +// segment direct-edge closures, the segment view of an image, and the codegen +// config a variant compiles with. +// +// The planner works on a hand-built KSEG0 image whose functions are given +// explicitly (so the test does not depend on discovery heuristics): +// F0 0x00 jal F1 / j F2 a call and a tail jump +// U 0x10 jr $ra reached by nothing +// F1 0x18 jr $ra F0's callee +// F2 0x20 beq -> F4, then runs a split branch out of the function and a +// off its end into F3 fall-through past its last block +// F3 0x2C jr $ra +// F4 0x38 jr $ra +// H 0x40 host with alias entries A1 0x44 and A2 0x48 (one shared body) +// F5 0x50 beq -> F1 as its last pair: taken and not taken both leave +// F6 0x58 jr $ra F5's not-taken path +// It checks: +// - a seed at a function entry roots that function; its closure follows +// calls, tail jumps, out-of-function branch targets and fall-throughs, +// and nothing else (U and H stay out); +// - a seed at a CPS continuation roots its owner; +// - an alias entry brings its whole alias group, not its host; +// - a seed that names no home dispatch entry is reported, not guessed, and +// so is a seed in a segment that does not map physical memory +// (0x20000000-0x7FFFFFFF, KSEG2); +// - plans come out per segment, in segment order, rebased: start/end, +// name, alias host, alias group and producer range all move into the +// segment; +// - direct_successors lists exactly the edges that leave a function. +// segment_view moves the header and keeps the bytes, and +// rebase_codegen_config moves the exact-match code sites that name the image +// into the variant's segment, keeps other addresses and the physically +// matched kinds as written, and turns the split pre-pass off. + +#include "segment_variants.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void check(bool ok, const std::string& what) { + if (!ok) { + std::printf("FAIL: %s\n", what.c_str()); + ++failures; + } +} + +constexpr uint32_t B = 0x80010000u; +constexpr uint32_t KUSEG = 0x00000000u, KSEG1 = 0xA0000000u; + +uint32_t jal(uint32_t t) { return 0x0C000000u | ((t >> 2) & 0x03FFFFFFu); } +uint32_t j(uint32_t t) { return 0x08000000u | ((t >> 2) & 0x03FFFFFFu); } +uint32_t beqz(uint32_t pc, uint32_t t) { // beq a0, zero, t + return 0x10800000u | (((t - (pc + 4u)) >> 2) & 0xFFFFu); +} +constexpr uint32_t JR_RA = 0x03E00008u, NOP = 0u, ADDIU_V0 = 0x24020001u; + +PSXRecomp::PS1Executable image() { + std::vector w(0x60 / 4, NOP); + auto at = [&](uint32_t off, uint32_t word) { w[off / 4] = word; }; + at(0x00, jal(B + 0x18)); at(0x08, j(B + 0x20)); // F0 + at(0x10, JR_RA); // U + at(0x18, JR_RA); // F1 + at(0x20, beqz(B + 0x20, B + 0x38)); at(0x28, ADDIU_V0); // F2 + at(0x2C, JR_RA); // F3 + at(0x38, JR_RA); // F4 + at(0x40, ADDIU_V0); at(0x44, ADDIU_V0); at(0x48, JR_RA); // H, A1, A2 + at(0x50, beqz(B + 0x50, B + 0x18)); // F5 + at(0x58, JR_RA); // F6 + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = B; + exe.header.initial_pc = B; + exe.header.file_size = static_cast(w.size() * 4u); + for (uint32_t word : w) + for (int i = 0; i < 4; ++i) exe.code_data.push_back(static_cast(word >> (8 * i))); + return exe; +} + +PSXRecomp::Function fn(uint32_t lo, uint32_t hi, uint32_t alias_host = 0, + std::vector group = {}) { + PSXRecomp::Function f; + f.start_addr = lo; + f.end_addr = hi; + f.size = hi - lo; + f.has_prologue = f.has_epilogue = false; + f.stack_frame_size = 0; + f.name = "func_" + std::to_string(lo); + f.alias_walk_lo = alias_host; + f.alias_group_entries = std::move(group); + return f; +} + +std::string hex(uint32_t v) { + char b[16]; + std::snprintf(b, sizeof b, "%08X", v); + return b; +} + +std::set starts(const PSXRecomp::SegmentVariantPlan& p) { + std::set out; + for (const auto& f : p.functions) out.insert(f.start_addr); + return out; +} + +} // namespace + +int main() { + const auto exe = image(); + const std::vector group{B + 0x44, B + 0x48}; + const std::vector home{ + fn(B + 0x00, B + 0x10), fn(B + 0x10, B + 0x18), fn(B + 0x18, B + 0x20), + fn(B + 0x20, B + 0x2C), fn(B + 0x2C, B + 0x34), fn(B + 0x38, B + 0x40), + fn(B + 0x40, B + 0x50), fn(B + 0x44, B + 0x50, B + 0x40, group), + fn(B + 0x48, B + 0x50, B + 0x40, group), fn(B + 0x50, B + 0x58), + fn(B + 0x58, B + 0x60)}; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfgs = analyzer.analyze_all_functions(home); + + // direct_successors: the edges that leave a function, and only those. + { + const auto f0 = PSXRecomp::direct_successors(cfgs.at(B)); + check(f0 == std::vector{B + 0x18, B + 0x20}, + "F0 leaves by its jal and its j, got " + std::to_string(f0.size())); + const auto f2 = PSXRecomp::direct_successors(cfgs.at(B + 0x20)); + check(f2 == std::vector{B + 0x2C, B + 0x38}, + "F2 leaves by its branch target and by running off its end"); + check(PSXRecomp::direct_successors(cfgs.at(B + 0x18)).empty(), + "a jr $ra is no direct edge"); + check(PSXRecomp::direct_successors(cfgs.at(B + 0x50)) == + std::vector{B + 0x18, B + 0x58}, + "F5's final branch leaves both taken and not taken"); + } + + const std::map conts{{B + 0x08, B}}; + const std::vector seeds{KSEG1 | B, KUSEG | ((B + 0x44) & 0x1FFFFFFFu), + KSEG1 | ((B + 0x08) & 0x1FFFFFFFu), + KSEG1 | ((B + 0x04) & 0x1FFFFFFFu)}; + const auto plans = PSXRecomp::plan_segment_variants(exe, home, cfgs, conts, seeds); + check(plans.size() == 2 && plans[0].segment == KUSEG && plans[1].segment == KSEG1, + "one plan per requested segment, in segment order"); + if (plans.size() == 2) { + const auto& ku = plans[0]; + const auto& k1 = plans[1]; + const uint32_t p = B & 0x1FFFFFFFu; + check(starts(ku) == std::set{p + 0x44, p + 0x48}, + "an alias entry brings its alias group, not its host"); + check(starts(k1) == std::set{KSEG1 | p, KSEG1 | (p + 0x18), KSEG1 | (p + 0x20), + KSEG1 | (p + 0x2C), KSEG1 | (p + 0x38)}, + "the KSEG1 closure is F0 with its call, tail jump, branch target and " + "fall-through, and neither U nor H"); + check(k1.seeds.size() == 3 && ku.seeds.size() == 1, "every seed is listed with its plan"); + check(k1.unplaced.size() == 1 && k1.unplaced[0].first == (KSEG1 | (p + 4)) && + k1.unplaced[0].second.find(hex(B + 4)) != std::string::npos, + "a seed inside a function body names no dispatch entry and is reported " + "with its home PC"); + bool sorted = true, rebased = true; + for (size_t i = 0; i < k1.functions.size(); ++i) { + const auto& f = k1.functions[i]; + if (i && k1.functions[i - 1].start_addr >= f.start_addr) sorted = false; + if ((f.end_addr & 0xE0000000u) != KSEG1 || f.name != "func_" + hex(f.start_addr)) + rebased = false; + } + check(sorted && rebased, "KSEG1 functions are sorted, with rebased ends and names"); + for (const auto& f : ku.functions) { + check(f.alias_walk_lo == p + 0x40 && + f.alias_group_entries == std::vector{p + 0x44, p + 0x48} && + f.name == "func_" + hex(f.start_addr), + "alias host and group move into the variant's segment"); + } + } + // A branch whose not-taken path runs past the function brings the next. + { + const auto f5 = PSXRecomp::plan_segment_variants( + exe, home, cfgs, conts, {KSEG1 | ((B + 0x50) & 0x1FFFFFFFu)}); + const uint32_t p = B & 0x1FFFFFFFu; + check(f5.size() == 1 && + starts(f5[0]) == std::set{KSEG1 | (p + 0x18), KSEG1 | (p + 0x50), + KSEG1 | (p + 0x58)}, + "F5's closure is F1 (taken) and F6 (not taken)"); + } + // Only KUSEG below 0x20000000, KSEG0 and KSEG1 map physical memory: a seed + // in 0x20000000-0x7FFFFFFF or in KSEG2 is no alias of the image's bytes. + // It is reported with its home PC and compiles nothing. + { + const uint32_t p = B & 0x1FFFFFFFu; + const auto bad = PSXRecomp::plan_segment_variants( + exe, home, cfgs, conts, {0x20000000u | p, 0xC0000000u | p, 0x60000000u | p}); + bool refused = bad.size() == 3; + for (const auto& plan : bad) { + refused = refused && plan.functions.empty() && plan.unplaced.size() == 1 && + plan.unplaced[0].first == (plan.segment | p) && + plan.unplaced[0].second.find("does not map physical memory") != + std::string::npos && + plan.unplaced[0].second.find(hex(B)) != std::string::npos; + } + check(refused, "a seed in a segment that does not map physical memory is " + "reported with its home PC and compiles nothing"); + check(PSXRecomp::maps_physical(0x1FFFFFFCu) && !PSXRecomp::maps_physical(0x20000000u) && + !PSXRecomp::maps_physical(0x7FFFFFFCu) && PSXRecomp::maps_physical(0x80000000u) && + PSXRecomp::maps_physical(0xBFFFFFFCu) && !PSXRecomp::maps_physical(0xC0000000u) && + !PSXRecomp::maps_physical(0xFFFE0130u), + "maps_physical is KUSEG below 0x20000000, KSEG0 and KSEG1"); + } + // A seed at a CPS continuation alone roots its owner. + { + const auto only = PSXRecomp::plan_segment_variants( + exe, home, cfgs, conts, {KSEG1 | ((B + 0x08) & 0x1FFFFFFFu)}); + check(only.size() == 1 && only[0].unplaced.empty() && + starts(only[0]).count(KSEG1 | (B & 0x1FFFFFFFu)), + "a continuation seed roots the function that owns it"); + } + + // segment_view: the header moves, the bytes stay. + { + const auto view = PSXRecomp::segment_view(exe, KSEG1); + check(view.load_address() == (KSEG1 | (B & 0x1FFFFFFFu)) && + view.entry_point() == (KSEG1 | (B & 0x1FFFFFFFu)) && + view.link_segment() == KSEG1 && view.code_data == exe.code_data, + "the KSEG1 view moves the header and keeps the bytes"); + check(view.read_word(KSEG1 | ((B + 0x18) & 0x1FFFFFFFu)) == exe.read_word(B + 0x18), + "the view reads the image's words at its own VAs"); + } + + // rebase_codegen_config: exact-match sites naming the image move; the + // rest is kept as written. + { + PSXRecomp::CodeGenConfig cfg; + cfg.hot_funcs = {B + 0x18, 0x80090000u}; + cfg.mod_function_entry_funcs = {B}; + cfg.ws_cull_bias_sites = {B + 0x28}; + cfg.persist_init_store_sites = {B + 0x28}; + cfg.vsync_query_hle_funcs[B + 0x20] = {1u, 2u, 3u, 4u}; + cfg.ws_bg2d_count_site = B + 0x28; + cfg.ws_bg2d_init_func = 0; + PSXRecompV4::WidescreenSignedBoundSite bound{}; + bound.address = B + 0x28; + cfg.ws_signed_x_bound_sites = {bound}; + const auto v = PSXRecomp::rebase_codegen_config(cfg, exe, KSEG1); + const uint32_t p = B & 0x1FFFFFFFu; + check(v.hot_funcs == std::set{KSEG1 | (p + 0x18), 0x80090000u}, + "an in-image site moves into the segment; an address outside the image stays"); + check(v.mod_function_entry_funcs == std::set{KSEG1 | p} && + v.ws_cull_bias_sites == std::set{KSEG1 | (p + 0x28)} && + v.persist_init_store_sites == std::set{KSEG1 | (p + 0x28)}, + "hook and site sets move"); + check(v.vsync_query_hle_funcs.size() == 1 && + v.vsync_query_hle_funcs.count(KSEG1 | (p + 0x20)) && + v.vsync_query_hle_funcs.at(KSEG1 | (p + 0x20))[3] == 4u, + "the VSync-query function moves; its data addresses do not"); + check(v.ws_bg2d_count_site == (KSEG1 | (p + 0x28)) && v.ws_bg2d_init_func == 0, + "scalar sites move; an unset one stays unset"); + check(v.ws_signed_x_bound_sites.size() == 1 && + v.ws_signed_x_bound_sites[0].address == B + 0x28, + "a physically matched site is kept as written"); + check(!v.split_mid_function_targets && cfg.split_mid_function_targets, + "a variant compiles the home compile's final pieces: no split pre-pass"); + } + + if (failures) { + std::printf("%d check(s) failed\n", failures); + return 1; + } + std::printf("PASS: segment variants plan direct-edge closures per segment, rebased\n"); + return 0; +} diff --git a/recompiler/tests/test_kuseg_dispatch_lookup.py b/recompiler/tests/test_kuseg_dispatch_lookup.py index 36b1e99d..516cdc89 100644 --- a/recompiler/tests/test_kuseg_dispatch_lookup.py +++ b/recompiler/tests/test_kuseg_dispatch_lookup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Recompiler codegen regression test: a KUSEG-linked EXE compiles for KUSEG, -and dispatch is exact (docs/SEGMENT_AWARE_CODE.md §5.3, §5.5). +segment-qualified seeds compile segment variants, and dispatch is exact +(docs/SEGMENT_AWARE_CODE.md §5.3, §5.4, §5.5). PS1 segments alias the same physical RAM, but a PC carries its segment into every link value, EPC and I-cache tag. Most PS-X EXE headers carry KSEG0 @@ -16,8 +17,19 @@ KSEG0 or KSEG1 alias ran it too. Now: PC, so an alias PC with no body of its own misses; psx_game_address_in_text stays physical, which sends that segment miss to the interpreter; - a seed in the link segment is an entry; a seed for the same bytes in - another segment is reported as a variant request (§5.4), never folded into - the home body and never silently dropped; + another segment is a variant request (§5.4), never folded into the home + body: the home functions it reaches through direct edges are compiled + again in its segment, with their own names, rows and baked PCs (a cached + variant is the home C with the segment swapped; a KSEG1 one is charged a + fetch per instruction), and several rows then share a physical word in + both lookup forms. A jump-table case whose value is in another segment + than its body tail-transfers instead of continuing locally, in a variant + and in a home compile alike. A variant closure that reaches a data stub + compiles it with no dispatch row, and one that reaches an alias entry + compiles its whole alias group, whose shared body every shard can call + through the declarations header. A variant seed that names no home + dispatch entry, or that lies in a segment that does not map physical + memory (0x20000000-0x7FFFFFFF, KSEG2), stops the build; - a header whose entry and load address are in different segments is rejected, a KSEG1-linked image compiles as uncached (a fetch charge per instruction), and a config code site in a foreign segment is refused. @@ -126,6 +138,18 @@ def compile_and_run(compiler, harness, sources, include): subprocess.run([binary], check=True, cwd=tmp) +def compile_only(compiler, sources, includes): + """Compile `sources` without linking (syntax and declarations only).""" + if os.path.basename(compiler).lower() in ("cl", "cl.exe", "clang-cl", "clang-cl.exe"): + command = [compiler, "/nologo", "/Zs"] + ["/I" + i for i in includes] + sources + else: + command = [compiler, "-std=c11", "-fsyntax-only", + "-Werror=implicit-function-declaration"] + \ + [a for i in includes for a in ("-I", i)] + sources + r = subprocess.run(command, capture_output=True, text=True) + return r.returncode == 0, r.stdout + r.stderr + + def run(recompiler, args, cwd=ROOT): return subprocess.run([recompiler] + args, capture_output=True, text=True, cwd=cwd) @@ -155,6 +179,50 @@ def generate(recompiler, tmp, name, entry, load, seeds, body=None): return dsrc, full, r.stdout +def bodies_of(full): + """{func_ name: body text} of the emitted C.""" + return {m.group(1): m.group(2) for m in re.finditer( + r"^void (func_[0-9A-F]{8})\(CPUState\* cpu\)\s*\{(.*?)^\}", full, re.M | re.S)} + + +def to_segment(text, seg): + """Every 8-digit hex token of the image moved into `seg` (constants, + labels and names alike).""" + return re.sub(r"(?> 16 == (LOAD >> 16) else m.group(0), + text) + + +def build_switch_body(load=LOAD, case_seg=None): + """A bounded jump table in an image at `load`: sltiu/beq guard, lw of a + table of case PCs in `case_seg` (default: the image's own segment), jr. + Each case branches to one shared jr $ra.""" + if case_seg is None: + case_seg = load & SEG_MASK + body = w([0x3C080000 | (load >> 16), 0x25100100, 0, 0x2C620003, 0x10400010, 0, + 0x00031080, 0x00501021, 0x8C420000, 0, 0x00400008, 0, + 0x24020001, 0x10000007, 0, 0x24020002, 0x10000004, 0, + 0x24020003, 0x10000001, 0, 0x03E00008, 0]) + body += b"\x00" * (0x100 - len(body)) + phys = load & PHYS_MASK + return body + w([case_seg | (phys + 0x30), case_seg | (phys + 0x3C), case_seg | (phys + 0x48)]) + + +def build_stub_body(): + """A function whose jal target decodes as data: the target is a data stub + (psx_unknown_dispatch, no dispatch row), in the home compile and in a + variant that reaches it.""" + return w([0x27BDFFF8, 0xAFBF0004, jal(LOAD + 0x20), 0, 0x8FBF0004, 0x27BD0008, + 0x03E00008, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF]) + + +def build_alias_body(n=40): + """One straight-line host function with interior seeds at +0x10 and +0x20: + an alias group (one shared psx_alias_body_ body, a wrapper per entry).""" + return w([0x24420001] * n + [0x03E00008, 0]) + + def rows_of(src): tbl = re.search(r"k_psx_game_dispatch\[\] = \{(.*?)\n\};", src, re.DOTALL) if not tbl: @@ -185,24 +253,28 @@ def main(): check("Loaded 2 extra function addresses" in log, "KUSEG seeds in the link segment must be loaded (log: %r)" % re.findall(r"Loaded .*", log)) - check("variant seed 0x80010020 (entry, KSEG0): home PC would be 0x00010020" in log, - "a KSEG0 seed for a KUSEG image must be reported as a variant request") + check("variant seed 0x80010020 (entry, KSEG0): home PC would be 0x00010020" in log + and "Segment variant 0x80000000" in log, + "a KSEG0 seed for a KUSEG image must be compiled as a variant request") rows = rows_of(src) - check(len(rows) >= 2, "expected at least two dispatch entries, got %d" % len(rows)) - check(all((a & SEG_MASK) == KUSEG and (r == 0 or (r & SEG_MASK) == KUSEG) - for a, r, _ in rows), - "every row key and resume PC is a KUSEG PC: %s" % rows) - check(all((int(fn[5:], 16) & SEG_MASK) == KUSEG and (r != 0 or fn == "func_%08X" % a) - for a, r, fn in rows), - "func_ names are the KUSEG identity: %s" % rows) + check(len(rows) >= 3, "expected at least three dispatch entries, got %d" % len(rows)) + check(all((a & SEG_MASK) == (int(fn[5:], 16) & SEG_MASK) and + (r == 0 or (r & SEG_MASK) == (a & SEG_MASK)) for a, r, fn in rows), + "every row key and resume PC is in its body's segment: %s" % rows) + check(all(r != 0 or fn == "func_%08X" % a for a, r, fn in rows), + "entry rows name their own body: %s" % rows) + check([(a, fn) for a, _, fn in rows if (a & SEG_MASK) != KUSEG] == + [(KSEG0 | (LOAD + 0x20), "func_80010020")], + "the only row outside KUSEG is the requested KSEG0 variant: %s" % rows) check({a for a, r, _ in rows} >= {LOAD, LOAD + 0x20}, "both functions have rows") - check("void func_00010000(CPUState* cpu)" in full and - "void func_00010020(CPUState* cpu)" in full, - "bodies are named by their KUSEG PC (§5.1)") - check("cpu->gpr[31] = 0x00010010u;" in full, "the jal links a KUSEG PC") - check("psx_icache_fetch(cpu, 0x00010000u)" in full, "fetch tags are KUSEG") - check(not re.search(r"0x8001[0-9A-F]{4}u", full), - "no KSEG0 PC of the image appears in the KUSEG body") + bodies = bodies_of(full) + check({"func_00010000", "func_00010020", "func_80010020"} <= set(bodies), + "bodies are named by their PC, the variant's included (§5.1): %s" % sorted(bodies)) + home = bodies["func_00010000"] + bodies["func_00010020"] + check("cpu->gpr[31] = 0x00010010u;" in home, "the jal links a KUSEG PC") + check("psx_icache_fetch(cpu, 0x00010000u)" in home, "fetch tags are KUSEG") + check(not re.search(r"0x8001[0-9A-F]{4}u", home), + "no KSEG0 PC of the image appears in the KUSEG bodies") keys = [a & PHYS_MASK for a, _, _ in rows] check(keys == sorted(keys), "dispatch table is sorted by physical address") @@ -212,20 +284,154 @@ def main(): body = m.group(1) check("0x1FFFFFFFu" in body and "k_psx_game_dispatch_index" in body, "small resident tables index the physical word") - check(".addr != addr" in body, "the lookup requires the exact PC") + check("k_psx_game_dispatch[row].addr == addr" in body, + "a word with rows in two segments is searched for the exact PC") + + # -- segment variants: the direct-edge closure, per segment (§5.4) ---- + vsrc, vfull, vlog = generate( + args.recompiler, tmp, "variants", LOAD, LOAD, + [LOAD, LOAD + 0x20, KSEG0 | LOAD, KSEG1 | LOAD, KSEG0 | (LOAD + 0x10)]) + vrows = {a: (r, fn) for a, r, fn in rows_of(vsrc)} + for seg in (KSEG0, KSEG1): + name = "func_%08X" % (seg | LOAD) + want = {seg | LOAD: (0, name), seg | (LOAD + 0x10): (seg | (LOAD + 0x10), name), + seg | (LOAD + 0x20): (0, "func_%08X" % (seg | (LOAD + 0x20)))} + got = {a: v for a, v in vrows.items() if (a & SEG_MASK) == seg} + check(got == want, "the 0x%08X variant is A, its call return and its jal callee B " + "(the closure), with rows in that segment: %s" % (seg, got)) + vbodies = bodies_of(vfull) + for fn in ("func_00010000", "func_00010020"): + v0 = "func_%08X" % (KSEG0 | int(fn[5:], 16)) + check(to_segment(vbodies[fn], KSEG0) == vbodies[v0], + "a cached variant is the home C with the segment swapped (%s)" % v0) + v1 = vbodies["func_%08X" % (KSEG1 | int(fn[5:], 16))] + insns = re.findall(r"/\* 0x([0-9A-F]{8}): ", v1) + fetched = set(re.findall(r"psx_icache_fetch\(cpu, 0x([0-9A-F]{8})u\)", v1)) + check(insns and all(i in fetched and i.startswith("A") for i in insns), + "every instruction of a KSEG1 variant is charged its own fetch (§5.6)") + check(not re.search(r"0x[08]001[0-9A-F]{4}u", v1), + "a KSEG1 variant bakes no KUSEG or KSEG0 PC of the image") + + # A variant seed must name a home dispatch entry. + psx = os.path.join(tmp, "variants.psx") + bad_seeds = os.path.join(tmp, "unplaced.seeds.txt") + with open(bad_seeds, "w") as f: + f.write("0x%08X\n0x%08X\n" % (LOAD, KSEG0 | (LOAD + 4))) + r = run(args.recompiler, [psx, "--seeds", bad_seeds, "--out-dir", + os.path.join(tmp, "unplaced")]) + check(r.returncode != 0 and "variant seed 0x80010004 cannot be compiled" in r.stderr, + "a variant seed with no home dispatch entry stops the build:\n%s" + % (r.stderr or r.stdout)[-2000:]) + + # A jump-table case is local only in its own segment: the KSEG0 + # variant of a KUSEG switch tail-transfers every KUSEG case value. + _, sfull, _ = generate(args.recompiler, tmp, "switch", LOAD, LOAD, + [LOAD, KSEG0 | LOAD], body=build_switch_body()) + sbodies = bodies_of(sfull) + check("/* jump table 0x00010100" in sbodies["func_00010000"] and + re.search(r"case 0x00010030u:\s*\n(.*\n)*?\s*goto block_00010030;", + sbodies["func_00010000"]), + "the home body switches locally on its own segment's case PCs") + v = sbodies["func_80010000"] + check("jump table" not in v and not re.search(r"case 0x0001[0-9A-F]{4}u", v) and + "cpu->pc = _jt_80010028; return;" in v, + "the KSEG0 variant tail-transfers a KUSEG case value to dispatch") + + # A home compile follows the same rule. Its tables normally hold its + # own segment's PCs and stay local switches; a table whose values are + # another segment's PCs of the image's bytes (a KSEG0 image whose table + # holds KUSEG PCs) dispatches those cases with their full PC, where + # they are segment misses, instead of continuing in the KSEG0 body. + for case_seg, local in ((KSEG0, True), (KUSEG, False)): + name = "switch_k0_" + ("own" if local else "kuseg") + _, hfull, _ = generate(args.recompiler, tmp, name, KSEG0 | LOAD, KSEG0 | LOAD, + [KSEG0 | LOAD], body=build_switch_body(KSEG0 | LOAD, case_seg)) + h = bodies_of(hfull)["func_80010000"] + if local: + check("/* jump table 0x80010100" in h and + re.search(r"case 0x80010030u:\s*\n(.*\n)*?\s*goto block_80010030;", h), + "a KSEG0 home body switches locally on its own segment's case PCs") + else: + check("jump table" not in h and not re.search(r"case 0x0001[0-9A-F]{4}u", h) and + "cpu->pc = _jt_80010028; return;" in h, + "a KSEG0 home body tail-transfers a KUSEG case value to dispatch") + + # Only KUSEG below 0x20000000, KSEG0 and KSEG1 map the image's bytes. A + # seed in 0x20000000-0x7FFFFFFF or in KSEG2 names no code of the image: + # it stops the build with the link-segment spelling of its bytes. + for bad in (0x20000000 | LOAD, 0xC0000000 | LOAD): + bad_seeds = os.path.join(tmp, "badseg.seeds.txt") + with open(bad_seeds, "w") as f: + f.write("0x%08X\n0x%08X\n" % (LOAD, bad)) + r = run(args.recompiler, [psx, "--seeds", bad_seeds, "--out-dir", + os.path.join(tmp, "badseg")]) + check(r.returncode != 0 and + ("seed 0x%08X is in segment 0x%08X, which does not map physical memory" + % (bad, bad & SEG_MASK)) in r.stderr and + ("are 0x%08X in its link segment" % LOAD) in r.stderr and + "Segment variant" not in r.stdout, + "a seed in a segment that does not map physical memory stops the build:\n%s" + % (r.stderr or r.stdout)[-2000:]) + + # A variant closure that reaches a data stub compiles the stub in its + # segment, and the stub gets no dispatch row, as at home. + dsrc, dfull, _ = generate(args.recompiler, tmp, "stub", LOAD, LOAD, [LOAD, KSEG0 | LOAD], + body=build_stub_body()) + drows = {a for a, _, _ in rows_of(dsrc)} + check("psx_unknown_dispatch(cpu, 0x80010020u, 0x00010020u);" in + bodies_of(dfull).get("func_80010020", ""), + "the variant's jal target is compiled as its data stub") + check((KSEG0 | LOAD) in drows and not drows & {LOAD + 0x20, KSEG0 | (LOAD + 0x20)}, + "a data stub has no dispatch row, at home or in a variant: %s" + % sorted("%08X" % a for a in drows)) + + # A variant seed at an alias entry compiles its alias group: one shared + # psx_alias_body_ in the variant's segment and a wrapper per entry. + # The shards share one declarations header; every alias body a shard + # defines is declared there, so a wrapper in another shard can call it + # (compiled below against the header alone), and every emitted file + # compiles. + _, afull, _ = generate(args.recompiler, tmp, "alias", LOAD, LOAD, + [LOAD, LOAD + 0x10, LOAD + 0x20, KSEG0 | (LOAD + 0x10)], + body=build_alias_body()) + abodies = bodies_of(afull) + check("psx_alias_body_80010000(cpu, 0x80010010u);" in abodies.get("func_80010010", "") and + "psx_alias_body_80010000(cpu, 0x80010020u);" in abodies.get("func_80010020", ""), + "an alias entry's variant brings its whole alias group: %s" % sorted(abodies)) + adir = os.path.join(tmp, "alias") + hosts = sorted(set(re.findall(r"^void (psx_alias_body_[0-9A-F]{8})\(", afull, re.M))) + check(hosts == ["psx_alias_body_00010000", "psx_alias_body_80010000"], + "the home and the variant alias bodies are emitted: %s" % hosts) + if args.compiler: + probe = os.path.join(tmp, "alias_decls_probe.c") + with open(probe, "w") as f: + f.write('#include "alias.psx_decls.h"\n' + "void (*const alias_bodies_probe[])(CPUState*, uint32_t) = {%s};\n" + % ", ".join(hosts)) + sources = [probe] + sorted(os.path.join(adir, n) for n in os.listdir(adir) + if n.endswith(".c")) + ok, out = compile_only(args.compiler, sources, [adir, os.path.join(ROOT, "runtime", "include")]) + check(ok, "every alias body is declared in the shared header and the variant " + "output compiles:\n" + out[-3000:]) # -- a table spanning 2 MiB or more takes the binary-search form ------ wide_src, _, _ = generate(args.recompiler, tmp, "kuseg_wide", LOAD, LOAD, - [LOAD, LOAD + WIDE_SPAN], body=build_wide_body()) + [LOAD, LOAD + WIDE_SPAN, KSEG1 | (LOAD + WIDE_SPAN)], + body=build_wide_body()) wide_rows = rows_of(wide_src) - check({LOAD, LOAD + WIDE_SPAN} <= {a for a, _, _ in wide_rows} and - all((a & SEG_MASK) == KUSEG for a, _, _ in wide_rows), - "the wide KUSEG image has KUSEG rows at both ends: %s" % wide_rows) + check({LOAD, LOAD + WIDE_SPAN, KSEG1 | (LOAD + WIDE_SPAN)} <= + {a for a, _, _ in wide_rows} and + all((a & SEG_MASK) == KUSEG for a, _, _ in wide_rows + if a != KSEG1 | (LOAD + WIDE_SPAN)), + "the wide KUSEG image has KUSEG rows at both ends and one KSEG1 variant: %s" + % wide_rows) wm = re.search(r"static const PsxGameDispatchEntry\* psx_game_find_entry" r"\(uint32_t addr\) \{(.*?)\n\}", wide_src, re.DOTALL) check(wm and "k_psx_game_dispatch_index" not in wm.group(1) and - "while (lo < hi)" in wm.group(1), - "a table spanning 2 MiB or more binary-searches the physical word") + "while (lo < hi)" in wm.group(1) and + "k_psx_game_dispatch[row].addr == addr" in wm.group(1), + "a table spanning 2 MiB or more binary-searches the physical word, " + "then the exact PC among the word's rows") # -- JAL-driven discovery takes the call's segment, not KSEG0 -------- jsrc, _, jlog = generate(args.recompiler, tmp, "kuseg_jal", LOAD, LOAD, [], @@ -272,6 +478,16 @@ def main(): if ok: check(r.returncode == 0, "a link-segment config site is accepted:\n%s" % (r.stderr or r.stdout)[-2000:]) + # The seeds ask for a KSEG0 variant of that function: a config + # code site applies to every segment compiled for its bytes. + hot = "" + for n in os.listdir(os.path.join(tmp, "cfg_%08X" % site)): + if n.endswith(".c"): + with open(os.path.join(tmp, "cfg_%08X" % site, n)) as f: + hot += f.read() + for fn in ("func_00010020", "func_80010020"): + check("__attribute__((hot))\n#endif\nvoid %s(CPUState* cpu)" % fn in hot, + "the link-segment hot_funcs site reaches %s" % fn) else: check(r.returncode != 0 and "recompiler.hot_funcs: 0x80010020 -> 0x00010020" in r.stderr, @@ -402,12 +618,14 @@ int main(void) { expected = &k_psx_game_dispatch[i]; assert(psx_game_find_entry(addr) == expected); assert(psx_game_is_function_entry(addr) == (expected != 0)); - if (segments[s] != 0x00000000u) assert(!expected); + /* Outside KUSEG only the requested variant has a body. */ + if (segments[s] != 0x00000000u) assert(!expected || addr == 0x80010020u); + if (addr == 0x80010020u) assert(expected && expected->addr == addr); int has_home = 0; for (unsigned i = 0; i < PSX_GAME_DISPATCH_COUNT; ++i) if ((k_psx_game_dispatch[i].addr & 0x1FFFFFFFu) == phys) has_home = 1; if (has_home) assert(psx_game_address_in_text(addr)); - if (has_home && segments[s] != 0x00000000u) { + if (has_home && !expected) { int old_calls = calls, old_irqs = irqs; cpu.pc = 0xDEADBEEFu; assert(!psx_game_text_native_ok(addr)); @@ -448,7 +666,7 @@ int main(void) { assert(!psx_game_find_entry(0xFFFFFFFFu)); assert(!psx_game_find_entry(0)); } - assert(home_hits >= 4); /* both functions, both geometries */ + assert(home_hits >= 6); /* both functions and the variant, both geometries */ return 0; } """ @@ -484,6 +702,8 @@ int main(void) { assert(PSX_GAME_DISPATCH_COUNT >= 2); for (unsigned i = 0; i < PSX_GAME_DISPATCH_COUNT; ++i) { const uint32_t phys = k_psx_game_dispatch[i].addr & 0x1FFFFFFFu; + /* Each word once: a variant's row follows its home row's word. */ + if (i && (k_psx_game_dispatch[i - 1].addr & 0x1FFFFFFFu) == phys) continue; for (unsigned s = 0; s < 3; ++s) { for (int d = -4; d <= 4; d += 4) { const uint32_t addr = segments[s] | (phys + (uint32_t)d); @@ -492,7 +712,9 @@ int main(void) { if (k_psx_game_dispatch[j].addr == addr) expected = &k_psx_game_dispatch[j]; assert(psx_game_find_entry(addr) == expected); assert(psx_game_is_function_entry(addr) == (expected != 0)); - if (segments[s] != 0x00000000u) assert(!expected); + /* Outside KUSEG only the requested KSEG1 variant has a body. */ + if (segments[s] != 0x00000000u) + assert(!expected || addr == (0xA0000000u | (0x00010000u + 0x200000u))); const int before = calls; assert(psx_dispatch_game_compiled(&cpu, addr) == (expected != 0)); assert(calls == before + (expected != 0)); @@ -506,11 +728,15 @@ int main(void) { """ compile_and_run(args.compiler, wide, [], include) - print("KUSEG-linked EXE test passed (%d KUSEG rows, exact lookup in the indexed and " - "binary-search forms, aliases miss, variant seeds reported, KSEG1 image charged " - "per instruction, foreign config sites and directives refused, physically " - "matched sites accepted, mirror PCs never run compiled code in 2 or 8 MiB " - "geometry)" % len(rows)) + print("KUSEG-linked EXE test passed (%d rows, exact lookup in the indexed and " + "binary-search forms with variant rows, unrequested aliases miss, variants " + "compiled as their direct-edge closure, cached variants equal the home C, KSEG1 " + "variants and images charged per instruction, foreign jump-table cases " + "dispatched at home and in variants, data stubs rowless, variant alias groups " + "declared for every shard, seeds outside the physical segments refused, foreign " + "config sites and directives refused, physically matched " + "sites accepted, mirror PCs never run compiled code in 2 or 8 MiB geometry)" + % len(rows)) if __name__ == "__main__": diff --git a/recompiler/tests/test_segment_aware_codegen.py b/recompiler/tests/test_segment_aware_codegen.py index f77f74e0..d9494779 100644 --- a/recompiler/tests/test_segment_aware_codegen.py +++ b/recompiler/tests/test_segment_aware_codegen.py @@ -45,10 +45,7 @@ SEG_MASK, PHYS_MASK = 0xE0000000, 0x1FFFFFFF # id -> (design section, what is missing). Remove an entry in the change # that closes it; the test fails until the ledger matches. -KNOWN_GAPS = { - "segment-variants": ("5.4", "a seeded KSEG1 entry gets no body of its own"), - "kseg1-fetch-charge": ("5.6", "compiled KSEG1 code does not charge +4 per fetch"), -} +KNOWN_GAPS = {} # Closed ids that stay as regression guards: each reports a new gap if the # property breaks again. id -> design section. @@ -66,6 +63,10 @@ REGRESSION_GUARDS = { "home-seed-accepted": "5.3", "alias-fetch-coherence": "5.3", "segment-miss": "5.5", + # PR D: segment-qualified seeds compile per-segment variants, and KSEG1 + # variants charge a fetch per instruction. + "segment-variants": "5.4", + "kseg1-fetch-charge": "5.6", } @@ -431,6 +432,19 @@ def main(): gaps.add("segment-variants") notes["segment-variants"] = ", ".join( "0x%08X->%s" % (q, "0x%08X" % found[q][0] if found[q] else "miss") for q in bad) + else: + # A variant body is its segment's code identity: every PC it bakes + # (links, fetch tags, IRQ resume PCs, exits and continuation keys, + # store PCs) is in that segment, and so is its name. + foreign = sorted({pc for q in variant_q for gid, pattern in PC_SITES.items() + for pc in pc_constants(bodies[found[q][2]], pattern) + if (pc & SEG_MASK) != (q & SEG_MASK)} | + {int(found[q][2][5:], 16) for q in variant_q + if (int(found[q][2][5:], 16) & SEG_MASK) != (q & SEG_MASK)}) + if foreign: + gaps.add("segment-variants") + notes["segment-variants"] = "%d PCs baked in another segment, e.g. 0x%08X" % ( + len(foreign), foreign[0]) wrong_home = [q for q in home_q if not found[q] or found[q][0] != q] stray = [q for q in miss_q if found[q]] if stray or wrong_home: @@ -445,9 +459,11 @@ def main(): if (pc & SEG_MASK) != link}) if gid == "resume-pc-segment": # The dispatch rows' keys and resume PCs are PCs too (§5.2): - # dispatch sets cpu->pc = resume_pc before entering a body. - off = sorted(set(off) | {pc for a, r, _ in rows for pc in (a, r) - if pc and (pc & SEG_MASK) != link}) + # dispatch sets cpu->pc = resume_pc before entering a body, so + # each row's key and resume PC are in the segment of the body + # it enters (the home body's, or a variant's, §5.4). + off = sorted(set(off) | {pc for a, r, fn in rows for pc in (a, r) + if pc and (pc & SEG_MASK) != (int(fn[5:], 16) & SEG_MASK)}) if off: gaps.add(gid) notes[gid] = "%d constants, e.g. 0x%08X" % (len(off), off[0]) @@ -506,6 +522,7 @@ def main(): run_recompiler(args.bios_recompiler, ["--config", openbios_toml, "--out-dir", tmp]) with open(os.path.join(tmp, "OpenBIOS_full.c")) as f: bios_c = f.read() + total, uncharged = bios_uncharged_kseg1(bios_c) if total == 0: model_fail.append("OpenBIOS: no KSEG1 instructions found (parser drift?)") diff --git a/tools/collect_game_misses.py b/tools/collect_game_misses.py index 312d32fa..7d51d577 100644 --- a/tools/collect_game_misses.py +++ b/tools/collect_game_misses.py @@ -6,10 +6,11 @@ A seed is a PC, so it carries a segment (docs/SEGMENT_AWARE_CODE.md §5.3, §5.4). dirty_ram_stats keys its rows by physical address, so the tool writes each one in the title's link segment: the segment of its EXE's load address (KSEG0 0x8001xxxx for most titles, KUSEG 0x0001xxxx for Kula World or Alien -Resurrection). The recompiler reports a seed in any other segment as a -variant request and does not compile it as an entry. Segment misses (TCP -`segment_misses`: static text entered in a segment with no body of its own) -are written with their full PC; each is a variant request (§5.4). +Resurrection). The recompiler takes a seed in any other segment as a variant +request, not an entry: it compiles the home body's direct-edge closure again +in that segment (§5.4). Segment misses (TCP `segment_misses`: static text +entered in a segment with no body of its own) are written with their full PC, +so the next regeneration compiles a variant for each. The link segment comes from --segment, from --game-toml ([game].load_address, or the header of its [game].exe), or from game_text_lo when that is written as @@ -41,6 +42,13 @@ SEG_MASK = 0xE0000000 SEGMENTS = {"kuseg": 0x00000000, "kseg0": 0x80000000, "kseg1": 0xA0000000} +def maps_physical(pc): + """KUSEG below 0x20000000, KSEG0 and KSEG1 map physical memory; a PC + anywhere else addresses no RAM, so it names no code of the image, and the + recompiler refuses it as a seed.""" + return pc < 0x20000000 or (pc & SEG_MASK) in (0x80000000, 0xA0000000) + + def parse_segment(text): key = text.strip().lower() if key in SEGMENTS: @@ -145,6 +153,10 @@ def main(argv=None): if sm.get("ok"): for row in sm.get("summary", []): vaddr = int(row["pc"], 16) + if not maps_physical(vaddr): + print("warning: segment miss 0x%08X is in a segment that does not map " + "physical memory; not a seed (the recompiler refuses it)" % vaddr) + continue if lo_phys <= (vaddr & PHYS_MASK) < hi_phys and vaddr not in known: new_entries.append((vaddr, row["count"], "segment miss: %d hits, home %s (variant request)" diff --git a/tools/tests/test_collect_game_misses.py b/tools/tests/test_collect_game_misses.py index 59bda6d2..a60d93f1 100644 --- a/tools/tests/test_collect_game_misses.py +++ b/tools/tests/test_collect_game_misses.py @@ -8,7 +8,9 @@ every dirty_ram miss as KSEG0 could never close a KUSEG-linked title's misses (Kula World, Alien Resurrection). dirty_ram_stats rows are physical; the tool takes the segment from --segment, --game-toml or a segmented game_text_lo, refuses to guess from a physical one, and writes `segment_misses` rows with -their full PC. +their full PC. A row in a segment that does not map physical memory +(0x20000000-0x7FFFFFFF, KSEG2) is reported and not written: the recompiler +refuses such a seed. A fake debug server answers the two commands the tool sends. Its `segment_misses` rows use the format string of the runtime's handler, read @@ -34,7 +36,9 @@ PER_PC = [ # physical, as dirty_ram_stats keys them {"pc": "0x00010200", "hits": 3, "insns": 12}, {"pc": "0x000C0000", "hits": 5, "insns": 7}, # outside the text range ] -SEGMENT_MISSES = [(0x800100F0, 0x000100F0, 4), (0xA0010124, 0x00010124, 1)] +SEGMENT_MISSES = [(0x800100F0, 0x000100F0, 4), (0xA0010124, 0x00010124, 1), + # 0x20000000-0x7FFFFFFF does not map physical memory: no seed. + (0x200100F0, 0x000100F0, 1)] class FakeDebugServer(threading.Thread): @@ -115,6 +119,9 @@ def main(): text = f.read() check("home 0x000100F0 (variant request)" in text, "a segment miss is written as a variant request: %r" % text) + check("segment miss 0x200100F0 is in a segment that does not map" in r.stdout, + "a segment miss outside the physical segments is reported, not " + "written: %r" % r.stdout) # A second run appends nothing: known seeds are exact PCs. r = run_tool(server.port, out, "--segment", "kuseg") check(r.returncode == 0 and "No new addresses" in r.stdout and