From d67f0f9396acdb7279b224a0707144e5b09586cd Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:49:25 -0700 Subject: [PATCH] recompiler: compile a KUSEG-linked EXE for its link segment; exact dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PS-X EXE header may carry KUSEG addresses (Alien Resurrection SLUS-00633, Kula World SCES-01000); the BIOS then runs the program at KUSEG PCs, so its links, EPCs and I-cache tags are KUSEG. The parser folded such a header to KSEG0, so the whole program compiled for a segment it never runs in, and the dispatch lookup matched any segment by physical address (docs/SEGMENT_AWARE_CODE.md §3.2, §5.3, §5.5). - EXE parser: keeps the header's addresses. link_segment() is the load address's segment; the load address and entry must share it and lie in the 8 MiB RAM window of KUSEG, KSEG0 or KSEG1 (KSEG1 compiles as an uncached home segment). phys_load_address()/contains_phys() do byte addressing; the BSS overlap check and the text_size bound are physical. - function_analysis: the three JAL scans take the target's top bits from the delay slot's PC instead of assuming KSEG0 (unchanged for KSEG0). - Seeds are range-checked by physical address. A seed in the link segment is an entry (0x000100E4 was silently dropped before). A seed for the same bytes in another segment asks for a segment variant (§5.4): it is listed in a WARNING, never folded into the home body. Seeds outside the image are counted. The producer_range, cross_call_allow and hosted_interior directives have no variant form: a foreign spelling is refused with the link-segment one instead of a bare "invalid" error. - Config code sites that are matched exactly (widescreen and cull sites, mod hooks, hot/data-shard funcs, the VSync-query function and its event-horizon return PCs, persisted options) and name the image's bytes in another segment are refused with the spelling that matches, since they would now match nothing. Kinds matched by physical address (byte patches, the full-word-guarded cull keep/angle/aspect-cone and signed-X-bound sites, dome call sites) reach the same bytes in any spelling and are not refused. Overlay compiles are exempt until PR E. - tools/collect_game_misses.py wrote every dirty-RAM miss as a KSEG0 seed, which a KUSEG-linked image now reports instead of compiling. It writes them in the link segment (--segment, --game-toml, or a segmented text range; it refuses to guess from a physical one) and adds the segment_misses rows at their full PC. - Dispatch source moves to game_dispatch_emitter.cpp. The lookup indexes the physical word, then requires row.addr == addr; a second row for one word is an error until variants share the table. psx_game_address_in_text stays physical, so a segment miss takes the clean-text-miss path (the previous commit records it). For a KSEG0 title the game C is byte-identical; its dispatch file changes only in psx_game_find_entry (the exact-PC compare) and its comment. Tests: - segment_aware_codegen closes link-segment, fetch-tag-segment, irq-resume-segment, resume-pc-segment, store-pc-segment, home-seed-accepted, alias-fetch-coherence and segment-miss; they stay as regression guards, and resume-pc-segment now also checks the dispatch rows' keys and resume PCs. - kuseg_dispatch_lookup: KUSEG rows, names and baked PCs; aliases miss while text stays physical; variant seeds reported; a function reached only by jal and a SaveState-style continuation are discovered in a KUSEG image (kills the two discovery scans reverted to KSEG0; the third, the invalid-JAL data heuristic, reads the same bits either way); mixed-segment headers rejected; a KSEG1 image charged per instruction; foreign config sites refused; the RAM-mirror contract as before. - kuseg_dispatch_lookup also compiles the lookup's binary-search form (a table spanning 2 MiB or more) and queries every segment's alias of each row; refuses a KSEG0 event-horizon site and KSEG0 seeds directives with their KUSEG spelling; accepts a KSEG0 signed_x_bound site and applies it. - emitter_runtime_pc_test: the dispatch rows follow runtime_pc() and keep the compile identity in func_ names (kills B's two surviving mutants: 76 and 12 failing checks); two rows on one physical word are an error. - collect_game_misses (new, tools/tests): link-segment seeds from each source, segment misses at their full PC, no guessing, no duplicates, against a fake server using the runtime's own summary row format. - reachable_discovery_test: the parser keeps KUSEG, accepts KSEG1, rejects mixed segments and non-RAM load addresses. Co-Authored-By: Claude Opus 5.5 --- psxrecomp_cli.py | 2 +- recompiler/CMakeLists.txt | 3 + recompiler/include/code_generator.h | 8 +- recompiler/include/ps1_exe_parser.h | 28 +- recompiler/src/function_analysis.cpp | 15 +- recompiler/src/game_dispatch_emitter.cpp | 299 +++++++++++ recompiler/src/game_dispatch_emitter.h | 42 ++ recompiler/src/main_psx.cpp | 463 +++++++---------- recompiler/src/ps1_exe_parser.cpp | 103 ++-- recompiler/tests/emitter_runtime_pc_test.cpp | 120 ++++- recompiler/tests/reachable_discovery_test.cpp | 41 +- .../tests/test_kuseg_dispatch_lookup.py | 467 ++++++++++++++---- .../tests/test_segment_aware_codegen.py | 25 +- .../tests/test_mirror_pc_preserved_guards.py | 2 +- tools/collect_game_misses.py | 199 ++++++-- tools/tests/test_collect_game_misses.py | 176 +++++++ 16 files changed, 1509 insertions(+), 484 deletions(-) create mode 100644 recompiler/src/game_dispatch_emitter.cpp create mode 100644 recompiler/src/game_dispatch_emitter.h create mode 100644 tools/tests/test_collect_game_misses.py diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index 964a4a54..d79be75b 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -1356,7 +1356,7 @@ def generated_game_is_cps(marker: Path) -> bool: """True when psxrecomp-game emitted continuation-passing game C. In CPS mode the emitter writes a psx_cps_mark_game constructor into the - dispatch it produces (recompiler/src/main_psx.cpp); overlay C compiled into + dispatch it produces (recompiler/src/game_dispatch_emitter.cpp); overlay C compiled into the same binary must use the same contract, so this reads the output rather than guessing from the environment.""" try: diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 0f54557a..d5938464 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -109,6 +109,7 @@ set(PSXRECOMP_GAME_SOURCES src/annotations.cpp src/config_loader.cpp src/recompiler_patch.cpp + src/game_dispatch_emitter.cpp src/main_psx.cpp ) @@ -598,6 +599,7 @@ if(BUILD_TESTING) src/control_flow.cpp src/function_analysis.cpp src/code_generator.cpp + src/game_dispatch_emitter.cpp src/strict_translator.cpp src/pgxp_hook_emitter.cpp src/annotations.cpp @@ -892,6 +894,7 @@ if(BUILD_TESTING) # (PSXRECOMP_LIBCHDR); its container/selection cases always run. foreach(_t IN ITEMS aligned_lzss_banks + collect_game_misses cursor_writers disc_companion duckstation_oracle diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 5095ea62..b5cbb8df 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -341,10 +341,10 @@ public: // store-PC stamps, the reserved-instruction EPC, the slice resume PC and // dispatch rows. Identity keys (func_/block_ names, .ranges, entry-hook // ids) keep the compile address. The default is the segment of the - // image's load address, so runtime_pc(addr) == addr for every address in - // the image and the output is byte-identical to the pre-segment emitter. - // The EXE parser folds KUSEG headers to KSEG0 (until §5.3), so today that - // default is KSEG0 for every executable title. + // image's load address, which is the EXE's link segment (§5.3: the parser + // keeps a KUSEG header's segment), so runtime_pc(addr) == addr for every + // address in the image. Only a compile of the same bytes for another + // segment (a variant, §5.4) sets a different code segment. static constexpr uint32_t kSegmentMask = 0xE0000000u; static constexpr uint32_t kPhysMask = 0x1FFFFFFFu; void set_code_segment(uint32_t seg) { code_seg_ = seg & kSegmentMask; } diff --git a/recompiler/include/ps1_exe_parser.h b/recompiler/include/ps1_exe_parser.h index aa563033..7aa253b0 100644 --- a/recompiler/include/ps1_exe_parser.h +++ b/recompiler/include/ps1_exe_parser.h @@ -8,6 +8,16 @@ namespace PSXRecomp { +// Segments of the MIPS address space (docs/SEGMENT_AWARE_CODE.md §2). A PC +// carries one: it reaches link values, EPCs and I-cache tags, and KSEG1 +// fetches are uncached. Data loads and stores reduce all three to the same +// physical address. +inline constexpr uint32_t kSegmentMask = 0xE0000000u; +inline constexpr uint32_t kPhysMask = 0x1FFFFFFFu; +inline constexpr uint32_t kSegKUSEG = 0x00000000u; +inline constexpr uint32_t kSegKSEG0 = 0x80000000u; +inline constexpr uint32_t kSegKSEG1 = 0xA0000000u; + // PS-X EXE header structure (2048 bytes) #pragma pack(push, 1) struct PS1ExeHeader { @@ -119,11 +129,27 @@ public: // that is not an overlay capture wrapped by compile_overlays. uint32_t analysis_guard_bytes = 0; - // Computed properties + // Computed properties. Addresses are the header's, in its link segment: + // the parser does not rewrite them (docs/SEGMENT_AWARE_CODE.md §5.3). uint32_t load_address() const { return header.load_address; } uint32_t entry_point() const { return header.initial_pc; } uint32_t code_size() const { return header.file_size; } + // The segment the program is linked for and runs in: KUSEG, KSEG0 or + // KSEG1. validate_header() requires the load address and the entry to + // share it. Code identity is the full virtual address in this segment + // (§5.1); an address in another segment names the same bytes but a + // different code identity (a segment variant, §5.4). + uint32_t link_segment() const { return header.load_address & kSegmentMask; } + + // Physical helpers for byte addressing: the same RAM in every segment. + uint32_t phys_load_address() const { return header.load_address & kPhysMask; } + bool contains_phys(uint32_t address) const { + const uint32_t phys = address & kPhysMask; + return phys >= phys_load_address() && + phys - phys_load_address() < header.file_size; + } + // READ bound: one past the last byte the image physically supplies. Used // by read_word so a mandatory delay slot living at a guard word resolves. uint32_t end_address() const { return header.end_address(); } diff --git a/recompiler/src/function_analysis.cpp b/recompiler/src/function_analysis.cpp index 5b1564db..8323aac0 100644 --- a/recompiler/src/function_analysis.cpp +++ b/recompiler/src/function_analysis.cpp @@ -440,9 +440,11 @@ bool FunctionAnalyzer::is_likely_data_section(uint32_t start_addr, uint32_t end_ // Check for JAL with invalid target if (opcode == 3) { // JAL opcode - // PS1 JAL target: upper 4 bits from PC region (0x80000000), low 28 bits from instr - uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u; - if (target > 0x801FFFFFu) { + // PS1 JAL target: upper 4 bits from the delay slot's PC (the + // image's own segment), low 28 bits from instr. Valid targets lie + // in the first 2 MiB of RAM. + uint32_t target = ((addr + 4u) & 0xF0000000u) | ((instr & 0x03FFFFFFu) << 2); + if ((target & 0x0FFFFFFFu) > 0x001FFFFFu) { invalid_jal_count++; } } @@ -1866,8 +1868,9 @@ FunctionAnalysisResult FunctionAnalyzer::analyze() { uint32_t opcode = (instr >> 26) & 0x3F; if (opcode == 3) { // JAL - // PS1 JAL target: upper 4 bits from KSEG0 region (0x80000000) - uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u; + // PS1 JAL target: upper 4 bits from the delay slot's PC, so a + // KUSEG-linked image calls KUSEG targets (§5.3). + uint32_t target = ((addr + 4u) & 0xF0000000u) | ((instr & 0x03FFFFFFu) << 2); // Only add if target is within EXE range and word-aligned if (target >= exe_start && target < exe_end && (target & 3) == 0) { @@ -2033,7 +2036,7 @@ FunctionAnalysisResult FunctionAnalyzer::analyze() { uint32_t opcode = (instr >> 26) & 0x3F; if (opcode != 3) continue; // JAL - uint32_t target = ((instr & 0x03FFFFFFu) << 2) | 0x80000000u; + uint32_t target = ((addr + 4u) & 0xF0000000u) | ((instr & 0x03FFFFFFu) << 2); uint32_t cont = addr + 8; if (target < exe_start || target >= exe_end || (target & 3) != 0) continue; if (cont < exe_start || cont >= exe_end || (cont & 3) != 0) continue; diff --git a/recompiler/src/game_dispatch_emitter.cpp b/recompiler/src/game_dispatch_emitter.cpp new file mode 100644 index 00000000..61183209 --- /dev/null +++ b/recompiler/src/game_dispatch_emitter.cpp @@ -0,0 +1,299 @@ +#include "game_dispatch_emitter.h" + +#include +#include +#include +#include +#include + +#include "fmt/format.h" + +namespace PSXRecomp { + +bool emit_game_dispatch(const CodeGenerator& codegen, + const PS1Executable& exe, + const std::set& dispatch_addrs, + const std::string& ranges_manifest, + std::string& out, + std::string& error) { + std::ostringstream ds; + ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; + ds << "#include \"psx_runtime.h\"\n\n"; + ds << "extern void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc);\n\n"; + ds << "extern int dirty_ram_text_native_ok_ranges_from(const uint32_t* lo_len_pairs, uint32_t count, uint32_t exec_pc);\n\n"; + ds << "extern int dirty_ram_text_native_ok_ranges(const uint32_t* lo_len_pairs, uint32_t count);\n\n"; + + // 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); + } + ds << "\n"; + + // Text membership is byte identity: physical, the same RAM in every + // segment. A PC in another segment is still game text, which is what + // routes a segment miss to the interpreter instead of the BIOS shell. + uint32_t game_text_start = exe.load_address() & 0x1FFFFFFFu; + uint32_t game_text_end = game_text_start + exe.code_size(); + ds << "int psx_game_address_in_text(uint32_t addr) {\n"; + ds << " uint32_t phys = addr & 0x1FFFFFFFu;\n"; + ds << fmt::format(" return phys >= 0x{:08X}u && phys < 0x{:08X}u;\n", + game_text_start, game_text_end); + ds << "}\n\n"; + + // Materialize one sorted data table for dispatch and entry probes. A + // giant sparse switch is catastrophically slow in Debug/-O0 builds: + // 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. + struct DispatchRecord { + uint32_t addr; + 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}); + } + } + // 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. + std::sort(records.begin(), records.end(), + [](const DispatchRecord& a, const DispatchRecord& b) { + return (a.addr & 0x1FFFFFFFu) < (b.addr & 0x1FFFFFFFu); + }); + for (size_t i = 1; i < records.size(); ++i) { + if ((records[i].addr & 0x1FFFFFFFu) == (records[i - 1].addr & 0x1FFFFFFFu)) { + 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); + return false; + } + } + + // Attach the exact CFG instruction ranges from the manifest to every + // dispatch owner. Mid-function alias wrappers have no separate F record, + // so resolve them to the containing host range. Mutable data and + // jump-table gaps sharing a page remain deliberately excluded. + using CodeRange = std::pair; // virtual lo, byte len + std::map> function_ranges; + { + std::istringstream input(ranges_manifest); + std::string line; + uint32_t current_owner = 0; + while (std::getline(input, line)) { + std::istringstream fields(line); + char kind = 0; + fields >> kind; + if (kind == 'F') { + fields >> std::hex >> current_owner; + function_ranges[current_owner]; + } else if (kind == 'R' && current_owner != 0) { + uint32_t lo = 0, len = 0; + fields >> std::hex >> lo >> len; + if (len != 0) + function_ranges[current_owner].push_back({lo, len}); + } + } + } + std::vector flat_ranges; + std::map> owner_spans; + auto resolve_owner_ranges = [&](uint32_t owner) + -> const std::vector* { + auto exact = function_ranges.find(owner); + if (exact != function_ranges.end() && !exact->second.empty()) + return &exact->second; + for (const auto& [entry, ranges] : function_ranges) { + (void)entry; + for (const auto& [lo, len] : ranges) { + const uint64_t hi = (uint64_t)lo + len; + if (owner >= lo && (uint64_t)owner < hi) return &ranges; + } + } + return nullptr; + }; + for (auto& rec : records) { + auto cached = owner_spans.find(rec.owner); + if (cached == owner_spans.end()) { + const uint32_t first = (uint32_t)flat_ranges.size(); + const auto* ranges = resolve_owner_ranges(rec.owner); + if (ranges) + flat_ranges.insert(flat_ranges.end(), ranges->begin(), ranges->end()); + const uint32_t count = (uint32_t)flat_ranges.size() - first; + cached = owner_spans.emplace(rec.owner, + std::make_pair(first, count)).first; + } + rec.range_index = cached->second.first; + rec.range_count = cached->second.second; + } + + ds << "typedef struct { uint32_t lo; uint32_t len; } PsxGameCodeRange;\n"; + ds << "static const PsxGameCodeRange k_psx_game_code_ranges[] = {\n"; + if (flat_ranges.empty()) ds << " {0u, 0u},\n"; + for (const auto& [lo, len] : flat_ranges) + ds << fmt::format(" {{0x{:08X}u, 0x{:X}u}},\n", lo, len); + ds << "};\n\n"; + + ds << "typedef void (*PsxGameDispatchFn)(CPUState* cpu);\n"; + ds << "typedef struct {\n"; + ds << " uint32_t addr;\n"; + ds << " uint32_t resume_pc;\n"; + ds << " uint32_t range_index;\n"; + ds << " uint32_t range_count;\n"; + 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); + } + ds << "};\n"; + ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); + // WO-6: resident dispatch keys are immutable. Index them once at build + // time instead of binary-searching on every overlay-to-EXE call and + // every text-validity query. This caches resolution, NEVER validity: + // 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); + 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 (indexed_lookup) { + 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; + 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"; + for (size_t i = 0; i < index.size(); ++i) { + if ((i & 15u) == 0) ds << " "; + ds << index[i] << "u,"; + ds << (((i & 15u) == 15u || i + 1 == index.size()) ? "\n" : " "); + } + ds << "};\n\n"; + } + ds << "/* A PC carries a segment (KUSEG 0x0..., KSEG0 0x8..., KSEG1 0xA...), and\n"; + ds << " * code identity is the full PC: each body bakes the links, EPCs, fetch\n"; + ds << " * tags and store PCs of the segment it was compiled for\n"; + ds << " * (docs/SEGMENT_AWARE_CODE.md §5.5). Find the physical word's row, then\n"; + ds << " * require the exact PC. A PC in another segment at the same word is a\n"; + ds << " * segment miss: no row here, but still game text (physical), so dispatch\n"; + 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) { + 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 || k_psx_game_dispatch[index - 1u].addr != addr) return 0;\n"; + ds << " return &k_psx_game_dispatch[index - 1u];\n"; + } else { + ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; + ds << " while (lo < hi) {\n"; + ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; + ds << " uint32_t key = k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu;\n"; + ds << " if (want < key) hi = mid;\n"; + ds << " else if (want > key) lo = mid + 1;\n"; + ds << " else return k_psx_game_dispatch[mid].addr == addr ? &k_psx_game_dispatch[mid] : 0;\n"; + ds << " }\n"; + ds << " return 0;\n"; + } + ds << "}\n\n"; + + ds << "/* Exact static-code validity for this entry's emitted CFG ranges. */\n"; + ds << "int psx_game_text_native_ok(uint32_t addr) {\n"; + ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; + ds << " if (!entry || entry->range_count == 0) return 0;\n"; + ds << " return dirty_ram_text_native_ok_ranges_from(\n"; + ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr);\n"; + ds << "}\n\n"; + + ds << "/* Full-range validity for straight-line interpreter-to-AOT handoff. */\n"; + ds << "int psx_game_text_native_ok_full(uint32_t addr) {\n"; + ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; + ds << " if (!entry || entry->range_count == 0) return 0;\n"; + ds << " return dirty_ram_text_native_ok_ranges(\n"; + ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count);\n"; + ds << "}\n\n"; + + ds << "/* Maps PS1 address to compiled game code. Returns 1 if dispatched, 0 if unknown. */\n"; + ds << "int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr) {\n"; + ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; + ds << " if (!entry) return 0;\n"; + 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()) + ds << " cpu->pc = entry->resume_pc;\n"; + ds << " /* load_accel: cycle-faithful VSync(-1) query (config-gated). */\n"; + ds << " {\n"; + ds << " extern int psx_vsync_query_hle_try(CPUState* cpu, uint32_t addr);\n"; + ds << " if (psx_vsync_query_hle_try(cpu, addr)) return 1;\n"; + ds << " }\n"; + ds << " entry->fn(cpu);\n"; + ds << " return 1;\n"; + ds << "}\n"; + + // Non-destructive companion to psx_dispatch_game_compiled: is `addr` a PC + // the top-level dispatch can re-enter (a compiled function entry, or a CPS + // continuation resume-point)? The precise event slicer (dirty_ram_interp.c) + // needs this to know when it may hand a resume PC back to the dispatcher: + // a MID-function clean-text PC is NOT re-enterable (the switch falls to + // `default: return 0` and the dirty path returns 0 -> top-level PC=0 exit), + // so the slicer must keep interpreting until cpu->pc lands on a true entry. + // Same precise address set as dispatch, without executing the entry. The + // runtime also asks it for the other segments' aliases of a missed PC to + // tell a segment miss from an interior one. + ds << "\n/* 1 iff addr is a re-enterable compiled entry/continuation (no exec). */\n"; + ds << "int psx_game_is_function_entry(uint32_t addr) {\n"; + ds << " return psx_game_find_entry(addr) != 0;\n"; + ds << "}\n"; + + if (codegen.cps_enabled()) { + // 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. + ds << "\n/* CPS runtime-mode marker (the overlay loader reads g_psx_cps_mode). */\n"; + ds << "static void psx_cps_mark_game(void) {\n"; + ds << " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; + ds << "}\n"; + // Run psx_cps_mark_game before main(). __attribute__((constructor)) is + // GCC/Clang-only; MSVC uses a static initializer pointer in .CRT$XCU. + ds << "#if defined(_MSC_VER)\n"; + ds << "#pragma section(\".CRT$XCU\", read)\n"; + ds << "__declspec(allocate(\".CRT$XCU\")) static void (*psx_cps_mark_game_ctor)(void) = psx_cps_mark_game;\n"; + ds << "#else\n"; + ds << "__attribute__((constructor)) static void psx_cps_mark_game_ctor(void) { psx_cps_mark_game(); }\n"; + ds << "#endif\n"; + } + + out = ds.str(); + return true; +} + +} // namespace PSXRecomp diff --git a/recompiler/src/game_dispatch_emitter.h b/recompiler/src/game_dispatch_emitter.h new file mode 100644 index 00000000..6f48fc87 --- /dev/null +++ b/recompiler/src/game_dispatch_emitter.h @@ -0,0 +1,42 @@ +#pragma once + +// The game dispatch table (_dispatch.c): every dispatchable compiled +// entry and CPS continuation of a PS-X EXE, keyed by the PC the guest +// executes, plus the lookup, validity and dispatch functions the runtime +// calls (psx_game_find_entry, psx_game_text_native_ok[_full], +// psx_dispatch_game_compiled, psx_game_is_function_entry). +// +// Code identity is the full virtual address (docs/SEGMENT_AWARE_CODE.md §5.1, +// §5.5): a row answers only for the exact PC it was compiled for. The lookup +// indexes rows by physical word and then requires row.addr == addr, so a PC in +// another segment at the same word misses. psx_game_address_in_text stays +// physical (byte identity), which sends such a segment miss down the runtime's +// clean-text-miss path: it is interpreted and recorded, never run through +// another segment's body. + +#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. +// +// 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. +bool emit_game_dispatch(const CodeGenerator& codegen, + const PS1Executable& exe, + const std::set& dispatch_addrs, + const std::string& ranges_manifest, + std::string& out, + std::string& error); + +} // namespace PSXRecomp diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 96a22ad2..446650b1 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include "function_analysis.h" #include "control_flow.h" #include "code_generator.h" +#include "game_dispatch_emitter.h" #include "annotations.hpp" #include "config_loader.h" #include "rabbitizer.hpp" @@ -245,6 +247,10 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_backdrop_unsquash; // [widescreen.backdrop] unsquash_funcs bool ws_auto_screen_x_cull = false; // [widescreen.cull] auto_screen_x std::set persist_init_sites; // [persist_options] init-store hooks (game_options.toml) + // [load_accel.vsync_query] event_horizon[_extra]_sites: return PCs the + // runtime compares exactly with $ra (load_accel.c). Not emitted; read here + // only for the segment check below. + std::vector vsync_horizon_sites, vsync_horizon_extra_sites; std::vector instruction_patches; std::vector ws_signed_x_bound_sites; bool ws_auto_backdrop_preload = false; // [widescreen.cull] auto_backdrop @@ -324,6 +330,8 @@ static int psxrecomp_game_main(int argc, char** argv) { if (cfg.ws_bg2d_cap_site) ws_bg2d_cap_site = cfg.ws_bg2d_cap_site; if (cfg.ws_bg2d_init_func) ws_bg2d_init_func = cfg.ws_bg2d_init_func; ws_signed_x_bound_sites = cfg.ws_signed_x_bound_sites; + vsync_horizon_sites = cfg.vsync_event_horizon_sites; + vsync_horizon_extra_sites = cfg.vsync_event_horizon_extra_sites; // [persist_options] init-store hook sites live in a dedicated // game_options.toml next to game.toml (the game's own native OPTION // settings, kept separate from game.toml/settings.toml). Best-effort: @@ -563,6 +571,84 @@ static int psxrecomp_game_main(int argc, char** argv) { exe->header.initial_pc); } + // Config code sites are code identities, and code identity is the full + // virtual address in the image's link segment (docs/SEGMENT_AWARE_CODE.md + // §5.1, §5.3). The kinds checked below are matched exactly (by the + // emitter, or by the runtime against a PC or $ra), so a site that names + // this image's bytes in another segment would silently match nothing (the + // EXE parser no longer folds a KUSEG header to KSEG0): refuse it and name + // the spelling that matches. Not checked, because they are matched by + // physical address and a foreign spelling already reaches the same bytes: + // [[recompiler.patches]] (byte patches), [widescreen.cull] keep, angle and + // aspect_cone sites, [[widescreen.signed_x_bound]] (the emitter's + // full-word-guarded substitutions) and [widescreen.dome] call_sites (the + // runtime masks them). Overlay compiles wrap captures at KSEG0 and take + // the game's config as --ws-config; their segment keys arrive with PR E + // (§5.7). + if (!overlay_mode) { + std::vector> foreign; + auto check = [&](const char* what, uint32_t addr) { + if (addr != 0 && exe->contains_phys(addr) && + (addr & PSXRecomp::kSegmentMask) != exe->link_segment()) + foreign.emplace_back(what, addr); + }; + auto check_set = [&](const char* what, const std::set& set) { + for (uint32_t a : set) check(what, a); + }; + check_set("widescreen.sprite_tag_funcs", ws_tag_funcs); + check_set("data_shards.funcs", ds_funcs); + check_set("recompiler.mod_function_entry_funcs", mod_entry_funcs); + check_set("recompiler.hot_funcs", hot_funcs); + check_set("recompiler.load_charge_batch_funcs", load_charge_batch_funcs); + for (const auto& [func, addrs] : vsync_query_hle_funcs) { + (void)addrs; + check("load_accel.vsync_query.func", func); + } + for (uint32_t site : vsync_horizon_sites) + check("load_accel.vsync_query.event_horizon_sites", site); + for (uint32_t site : vsync_horizon_extra_sites) + check("load_accel.vsync_query.event_horizon_extra_sites", site); + check_set("widescreen.cull.bias_sites", ws_cull_bias); + check_set("widescreen.cull.range_sites", ws_cull_range); + check_set("widescreen.cull.a1_sites", ws_cull_a1); + check_set("widescreen.cull.screen_x_sites", ws_cull_screen_x); + check_set("widescreen.cull.slti_sites", ws_cull_slti); + check_set("widescreen.cull.slti_lower_sites", ws_cull_slti_lower); + check_set("widescreen.cull.bltz_sites", ws_cull_bltz); + check_set("widescreen.cull.negsub_sites", ws_cull_negsub); + check_set("widescreen.cull.vxrange_sites", ws_cull_vxrange); + check_set("widescreen.cull.depth_sites", ws_cull_depth); + check_set("widescreen.cull.plane_nx_sites", ws_cull_plane_nx); + check_set("widescreen.cull.xclip_load_sites", ws_cull_xclip_load); + check_set("widescreen.cull.nclip_keep_sites", ws_cull_nclip_keep); + check_set("widescreen.cull.nclip_exact_sites", ws_cull_nclip_exact); + check_set("widescreen.cull.branch_keep_sites", ws_cull_branch_keep); + check_set("widescreen.cull.bgez_sites", ws_cull_bgez); + check_set("widescreen.cull.clip_edge_x_load_sites", ws_cull_clip_edge_x_load); + check_set("widescreen.backdrop.x_sites", ws_backdrop_x); + check_set("widescreen.backdrop.unsquash_funcs", ws_backdrop_unsquash); + check_set("persist_options.init_store_pc", persist_init_sites); + check("widescreen.bg2d.count_site", ws_bg2d_count_site); + check("widescreen.bg2d.startcol_site", ws_bg2d_startcol_site); + check("widescreen.bg2d.startx_site", ws_bg2d_startx_site); + check("widescreen.bg2d.stream_left_site", ws_bg2d_stream_left_site); + check("widescreen.bg2d.stream_right_site", ws_bg2d_stream_right_site); + check("widescreen.bg2d.bufbase_site", ws_bg2d_bufbase_site); + check("widescreen.bg2d.cap_site", ws_bg2d_cap_site); + check("widescreen.bg2d.init_func", ws_bg2d_init_func); + if (!foreign.empty()) { + fmt::print(stderr, + "ERROR: {} config code site(s) name this image's bytes in another " + "segment than its link segment 0x{:08X}. Code identity is the full " + "PC (docs/SEGMENT_AWARE_CODE.md §5.1), so write each in the link " + "segment:\n", foreign.size(), exe->link_segment()); + for (const auto& [what, addr] : foreign) + fmt::print(stderr, " {}: 0x{:08X} -> 0x{:08X}\n", what, addr, + exe->link_segment() | (addr & PSXRecomp::kPhysMask)); + return 1; + } + } + if (inspect_mode) { // Disassemble first 20 instructions from entry point fmt::print("Disassembly at Entry Point (0x{:08X}):\n", exe->header.initial_pc); @@ -681,8 +767,19 @@ static int psxrecomp_game_main(int argc, char** argv) { * Seeds are accepted within the loaded image's own bounds — overlay mode * wraps arbitrary regions (kernel RAM at 0x80000000, overlays at * 0x800E7000, ...), so a hardcoded game-RAM window would silently drop - * valid seeds. */ + * valid seeds. + * + * A seed is a PC, so it carries a segment (docs/SEGMENT_AWARE_CODE.md + * §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. */ std::vector file_seeds; + std::vector> variant_seeds; // PC, line kind + size_t outside_seeds = 0; std::vector interior_seeds; std::map hosted_interior_seeds; std::set hosted_interior_hosts; @@ -697,6 +794,42 @@ static int psxrecomp_game_main(int argc, char** argv) { if (ef.is_open()) { const uint32_t seed_lo = exe->header.load_address; const uint32_t seed_hi = exe->analysis_end_address(); + // The directive lines below name code by PC too, so they follow + // the seed rule (§5.3): an address that names this image's bytes + // in another segment is refused with its link-segment spelling, + // instead of failing the raw bounds check with no hint. (A + // variant request is a plain seed line; directives have none.) + // `end_exclusive`: the last address is a range end, which names + // the image when its last byte does. + auto foreign_directive = [&](const std::string& text, + std::initializer_list addrs, + bool end_exclusive = false) { + auto in_image = [&](uint32_t a, bool is_end) { + return is_end ? a != 0 && exe->contains_phys(a - 1u) + : exe->contains_phys(a); + }; + bool foreign = false; + size_t i = 0; + for (uint32_t a : addrs) { + const bool is_end = end_exclusive && ++i == addrs.size(); + foreign |= in_image(a, is_end) && + (a & PSXRecomp::kSegmentMask) != exe->link_segment(); + } + if (!foreign) return false; + std::string fixed; + i = 0; + for (uint32_t a : addrs) { + const bool is_end = end_exclusive && ++i == addrs.size(); + fixed += fmt::format(" 0x{:08X}", in_image(a, is_end) + ? exe->link_segment() | (a & PSXRecomp::kPhysMask) : a); + } + fmt::print(stderr, + "ERROR: seeds directive names this image's bytes in another " + "segment than its link segment 0x{:08X} " + "(docs/SEGMENT_AWARE_CODE.md §5.3): {}\n write its " + "addresses as:{}\n", exe->link_segment(), text, fixed); + return true; + }; std::string line; while (std::getline(ef, line)) { if (line.empty() || line[0] == '#') continue; @@ -708,6 +841,9 @@ static int psxrecomp_game_main(int argc, char** argv) { std::strtoul(lo_text.c_str(), nullptr, 16)); uint32_t hi = static_cast( std::strtoul(hi_text.c_str(), nullptr, 16)); + if (!lo_text.empty() && !hi_text.empty() && + foreign_directive(line, {lo, hi}, /*end_exclusive=*/true)) + return 1; if (lo_text.empty() || hi_text.empty() || lo < seed_lo || lo >= hi || hi > seed_hi) { fmt::print(stderr, "ERROR: invalid producer_range: {}\n", line); @@ -720,6 +856,7 @@ static int psxrecomp_game_main(int argc, char** argv) { const char* p = line.c_str() + 16; uint32_t addr = static_cast( std::strtoul(p, nullptr, 16)); + if (foreign_directive(line, {addr})) return 1; if (addr < seed_lo || addr >= seed_hi) { fmt::print(stderr, "ERROR: invalid cross_call_allow: {}\n", line); return 1; @@ -749,6 +886,11 @@ static int psxrecomp_game_main(int argc, char** argv) { return true; }; uint32_t target = 0, host = 0; + if (tag == "hosted_interior" && trailing.empty() && + parse_hex_u32(target_text, target) && + parse_hex_u32(host_text, host) && + foreign_directive(line, {target, host})) + return 1; if (tag != "hosted_interior" || !trailing.empty() || !parse_hex_u32(target_text, target) || !parse_hex_u32(host_text, host) || @@ -796,15 +938,21 @@ static int psxrecomp_game_main(int argc, char** argv) { p += 9; } uint32_t addr = (uint32_t)std::strtoul(p, nullptr, 16); - if (addr >= seed_lo && addr < seed_hi) { - if (interior) { - interior_seeds.push_back(addr); - } else { - if (trusted_root) trusted_root_seeds.insert(addr); - if (trusted_call_root) trusted_call_root_seeds.insert(addr); - file_seeds.push_back(addr); - exact_entries.push_back(addr); - } + const uint32_t phys = addr & PSXRecomp::kPhysMask; + if (phys < (seed_lo & PSXRecomp::kPhysMask) || + phys >= (seed_lo & PSXRecomp::kPhysMask) + (seed_hi - seed_lo)) { + ++outside_seeds; + } else if ((addr & PSXRecomp::kSegmentMask) != exe->link_segment()) { + variant_seeds.emplace_back( + addr, interior ? "interior" : trusted_root ? "dispatch_root" + : trusted_call_root ? "call_root" : "entry"); + } else if (interior) { + interior_seeds.push_back(addr); + } else { + if (trusted_root) trusted_root_seeds.insert(addr); + if (trusted_call_root) trusted_call_root_seeds.insert(addr); + file_seeds.push_back(addr); + exact_entries.push_back(addr); } } std::sort(producer_ranges.begin(), producer_ranges.end()); @@ -839,6 +987,30 @@ static int psxrecomp_game_main(int argc, char** argv) { trusted_call_root_seeds.size(), producer_ranges.size(), cross_call_allow.size(), extra_funcs_path); + if (outside_seeds) { + fmt::print(" {} seed(s) lie outside this image (0x{:08X}-0x{:08X}) " + "and were not used\n", + outside_seeds, seed_lo, seed_hi); + } + if (!variant_seeds.empty()) { + auto seg_name = [](uint32_t seg) { + return seg == PSXRecomp::kSegKUSEG ? "KUSEG" + : 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())); + 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), + exe->link_segment() | (addr & PSXRecomp::kPhysMask)); + } + } } else { fmt::print("WARNING: Cannot open extra-funcs file: {}\n", extra_funcs_path); } @@ -1503,271 +1675,14 @@ static int psxrecomp_game_main(int argc, char** argv) { std::filesystem::path dispatch_filename = out_dir / (exe_stem + "_dispatch.c"); fmt::print("Generating dispatch table: {}\n", dispatch_filename.string()); - std::ostringstream ds; - ds << "/* Generated by PSXRecomp - dynamic dispatch table */\n"; - ds << "#include \"psx_runtime.h\"\n\n"; - ds << "extern void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t resume_pc);\n\n"; - ds << "extern int dirty_ram_text_native_ok_ranges_from(const uint32_t* lo_len_pairs, uint32_t count, uint32_t exec_pc);\n\n"; - ds << "extern int dirty_ram_text_native_ok_ranges(const uint32_t* lo_len_pairs, uint32_t count);\n\n"; - - // 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); + std::string dispatch_source, dispatch_error; + if (!PSXRecomp::emit_game_dispatch(codegen, *exe, dispatch_addrs, + ranges_manifest, dispatch_source, + dispatch_error)) { + fmt::print(stderr, "ERROR: {}\n", dispatch_error); + return 1; } - ds << "\n"; - - // Dispatch function - uint32_t game_text_start = exe->load_address() & 0x1FFFFFFFu; - uint32_t game_text_end = game_text_start + exe->code_size(); - ds << "int psx_game_address_in_text(uint32_t addr) {\n"; - ds << " uint32_t phys = addr & 0x1FFFFFFFu;\n"; - ds << fmt::format(" return phys >= 0x{:08X}u && phys < 0x{:08X}u;\n", - game_text_start, game_text_end); - ds << "}\n\n"; - - // Materialize one sorted data table for dispatch and entry probes. A - // giant sparse switch is catastrophically slow in Debug/-O0 builds: - // 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. - struct DispatchRecord { - uint32_t addr; - 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}); - } - } - // Sort by PHYSICAL address. psx_game_find_entry() compares masked - // addresses so a KUSEG-executing guest still matches KSEG-normalized - // keys; the search invariant must therefore be the masked order too. - // Within one segment this is identical to sorting by the raw address. - std::sort(records.begin(), records.end(), - [](const DispatchRecord& a, const DispatchRecord& b) { - return (a.addr & 0x1FFFFFFFu) < (b.addr & 0x1FFFFFFFu); - }); - - // Attach the exact CFG instruction ranges from the manifest to every - // dispatch owner. Mid-function alias wrappers have no separate F record, - // so resolve them to the containing host range. Mutable data and - // jump-table gaps sharing a page remain deliberately excluded. - using CodeRange = std::pair; // virtual lo, byte len - std::map> function_ranges; - { - std::istringstream input(ranges_manifest); - std::string line; - uint32_t current_owner = 0; - while (std::getline(input, line)) { - std::istringstream fields(line); - char kind = 0; - fields >> kind; - if (kind == 'F') { - fields >> std::hex >> current_owner; - function_ranges[current_owner]; - } else if (kind == 'R' && current_owner != 0) { - uint32_t lo = 0, len = 0; - fields >> std::hex >> lo >> len; - if (len != 0) - function_ranges[current_owner].push_back({lo, len}); - } - } - } - std::vector flat_ranges; - std::map> owner_spans; - auto resolve_owner_ranges = [&](uint32_t owner) - -> const std::vector* { - auto exact = function_ranges.find(owner); - if (exact != function_ranges.end() && !exact->second.empty()) - return &exact->second; - for (const auto& [entry, ranges] : function_ranges) { - (void)entry; - for (const auto& [lo, len] : ranges) { - const uint64_t hi = (uint64_t)lo + len; - if (owner >= lo && (uint64_t)owner < hi) return &ranges; - } - } - return nullptr; - }; - for (auto& rec : records) { - auto cached = owner_spans.find(rec.owner); - if (cached == owner_spans.end()) { - const uint32_t first = (uint32_t)flat_ranges.size(); - const auto* ranges = resolve_owner_ranges(rec.owner); - if (ranges) - flat_ranges.insert(flat_ranges.end(), ranges->begin(), ranges->end()); - const uint32_t count = (uint32_t)flat_ranges.size() - first; - cached = owner_spans.emplace(rec.owner, - std::make_pair(first, count)).first; - } - rec.range_index = cached->second.first; - rec.range_count = cached->second.second; - } - - ds << "typedef struct { uint32_t lo; uint32_t len; } PsxGameCodeRange;\n"; - ds << "static const PsxGameCodeRange k_psx_game_code_ranges[] = {\n"; - if (flat_ranges.empty()) ds << " {0u, 0u},\n"; - for (const auto& [lo, len] : flat_ranges) - ds << fmt::format(" {{0x{:08X}u, 0x{:X}u}},\n", lo, len); - ds << "};\n\n"; - - ds << "typedef void (*PsxGameDispatchFn)(CPUState* cpu);\n"; - ds << "typedef struct {\n"; - ds << " uint32_t addr;\n"; - ds << " uint32_t resume_pc;\n"; - ds << " uint32_t range_index;\n"; - ds << " uint32_t range_count;\n"; - 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); - } - ds << "};\n"; - ds << fmt::format("#define PSX_GAME_DISPATCH_COUNT {}u\n\n", records.size()); - // WO-6: resident dispatch keys are immutable. Index them once at build - // time instead of binary-searching on every overlay-to-EXE call and - // every text-validity query. This caches resolution, NEVER validity: - // callers below still check live instruction ranges on every dispatch. - // Bound the indexed span to one PS1 RAM image; wider or ambiguous - // tables retain the existing 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); - bool indexed_lookup = !records.empty() && lookup_hi - lookup_lo < 0x200000u; - for (size_t i = 0; i < records.size(); ++i) { - const uint32_t key = records[i].addr & 0x1FFFFFFFu; - if ((key & 3u) || (i && key == (records[i - 1].addr & 0x1FFFFFFFu))) - indexed_lookup = false; - } - if (indexed_lookup) { - 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; - 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"; - for (size_t i = 0; i < index.size(); ++i) { - if ((i & 15u) == 0) ds << " "; - ds << index[i] << "u,"; - ds << (((i & 15u) == 15u || i + 1 == index.size()) ? "\n" : " "); - } - ds << "};\n\n"; - } - ds << "/* PS1 segments alias the same physical RAM. A game whose PS-X EXE\n"; - ds << " * header carries KUSEG addresses (load address and entry PC without the\n"; - ds << " * KSEG bit) executes with a KUSEG PC, while this table is keyed by the\n"; - ds << " * recompiler's KSEG-normalized addresses. Comparing raw values made every\n"; - ds << " * lookup fail for such a title: 0x0001xxxx is always below 0x8001xxxx, so\n"; - ds << " * the search collapsed and returned no entry, silently routing all game\n"; - ds << " * code to the interpreter. Compare the 29-bit physical address instead;\n"; - ds << " * the table is sorted by the same masked key. */\n"; - ds << "static const PsxGameDispatchEntry* psx_game_find_entry(uint32_t addr) {\n"; - ds << " const uint32_t want = addr & 0x1FFFFFFFu;\n"; - 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 << " return index ? &k_psx_game_dispatch[index - 1u] : 0;\n"; - } else { - ds << " uint32_t lo = 0, hi = PSX_GAME_DISPATCH_COUNT;\n"; - ds << " while (lo < hi) {\n"; - ds << " uint32_t mid = lo + (hi - lo) / 2;\n"; - ds << " uint32_t key = k_psx_game_dispatch[mid].addr & 0x1FFFFFFFu;\n"; - ds << " if (want < key) hi = mid;\n"; - ds << " else if (want > key) lo = mid + 1;\n"; - ds << " else return &k_psx_game_dispatch[mid];\n"; - ds << " }\n"; - ds << " return 0;\n"; - } - ds << "}\n\n"; - - ds << "/* Exact static-code validity for this entry's emitted CFG ranges. */\n"; - ds << "int psx_game_text_native_ok(uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry || entry->range_count == 0) return 0;\n"; - ds << " return dirty_ram_text_native_ok_ranges_from(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count, addr);\n"; - ds << "}\n\n"; - - ds << "/* Full-range validity for straight-line interpreter-to-AOT handoff. */\n"; - ds << "int psx_game_text_native_ok_full(uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry || entry->range_count == 0) return 0;\n"; - ds << " return dirty_ram_text_native_ok_ranges(\n"; - ds << " &k_psx_game_code_ranges[entry->range_index].lo, entry->range_count);\n"; - ds << "}\n\n"; - - ds << "/* Maps PS1 address to compiled game code. Returns 1 if dispatched, 0 if unknown. */\n"; - ds << "int psx_dispatch_game_compiled(CPUState* cpu, uint32_t addr) {\n"; - ds << " const PsxGameDispatchEntry* entry = psx_game_find_entry(addr);\n"; - ds << " if (!entry) return 0;\n"; - 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()) - ds << " cpu->pc = entry->resume_pc;\n"; - ds << " /* load_accel: cycle-faithful VSync(-1) query (config-gated). */\n"; - ds << " {\n"; - ds << " extern int psx_vsync_query_hle_try(CPUState* cpu, uint32_t addr);\n"; - ds << " if (psx_vsync_query_hle_try(cpu, addr)) return 1;\n"; - ds << " }\n"; - ds << " entry->fn(cpu);\n"; - ds << " return 1;\n"; - ds << "}\n"; - - // Non-destructive companion to psx_dispatch_game_compiled: is `addr` a PC - // the top-level dispatch can re-enter (a compiled function entry, or a CPS - // continuation resume-point)? The precise event slicer (dirty_ram_interp.c) - // needs this to know when it may hand a resume PC back to the dispatcher: - // a MID-function clean-text PC is NOT re-enterable (the switch falls to - // `default: return 0` and the dirty path returns 0 -> top-level PC=0 exit), - // so the slicer must keep interpreting until cpu->pc lands on a true entry. - // Same precise address set as dispatch, without executing the entry. - ds << "\n/* 1 iff addr is a re-enterable compiled entry/continuation (no exec). */\n"; - ds << "int psx_game_is_function_entry(uint32_t addr) {\n"; - ds << " return psx_game_find_entry(addr) != 0;\n"; - ds << "}\n"; - - if (codegen.cps_enabled()) { - // 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. - ds << "\n/* CPS runtime-mode marker (the overlay loader reads g_psx_cps_mode). */\n"; - ds << "static void psx_cps_mark_game(void) {\n"; - ds << " extern int g_psx_cps_mode; g_psx_cps_mode = 1;\n"; - ds << "}\n"; - // Run psx_cps_mark_game before main(). __attribute__((constructor)) is - // GCC/Clang-only; MSVC uses a static initializer pointer in .CRT$XCU. - ds << "#if defined(_MSC_VER)\n"; - ds << "#pragma section(\".CRT$XCU\", read)\n"; - ds << "__declspec(allocate(\".CRT$XCU\")) static void (*psx_cps_mark_game_ctor)(void) = psx_cps_mark_game;\n"; - ds << "#else\n"; - ds << "__attribute__((constructor)) static void psx_cps_mark_game_ctor(void) { psx_cps_mark_game(); }\n"; - ds << "#endif\n"; - } - - if (write_file_if_changed(dispatch_filename, ds.str())) { + if (write_file_if_changed(dispatch_filename, dispatch_source)) { fmt::print("✓ Dispatch table written ({} entries)\n\n", dispatch_addrs.size()); } else { diff --git a/recompiler/src/ps1_exe_parser.cpp b/recompiler/src/ps1_exe_parser.cpp index 19bbbdd9..91a18dd6 100644 --- a/recompiler/src/ps1_exe_parser.cpp +++ b/recompiler/src/ps1_exe_parser.cpp @@ -7,11 +7,24 @@ namespace PSXRecomp { namespace { -// KSEG0 main-RAM decode window. Retail 2 MiB DRAM mirrors across all of it -// (a game may place code or its stack in the 2nd-4th mirror) and expanded -// 8 MiB targets decode it uniquely; the runtime folds per its geometry. -constexpr uint64_t kRamWindowEnd = 0x80800000ull; +// Main-RAM decode window, 8 MiB at the start of each of KUSEG, KSEG0 and +// KSEG1. Retail 2 MiB DRAM mirrors across all of it (a game may place code or +// its stack in the 2nd-4th mirror) and expanded 8 MiB targets decode it +// uniquely; the runtime folds per its geometry. constexpr uint32_t kRamWindowBytes = 0x00800000u; + +// The segment a main-RAM address is in, or kNoSegment when it is not in one +// of the three windows (docs/SEGMENT_AWARE_CODE.md §5.3). +constexpr uint32_t kNoSegment = 0xFFFFFFFFu; +uint32_t ram_window_segment(uint32_t addr) { + const uint32_t seg = addr & kSegmentMask; + if (seg != kSegKUSEG && seg != kSegKSEG0 && seg != kSegKSEG1) return kNoSegment; + return (addr & kPhysMask) < kRamWindowBytes ? seg : kNoSegment; +} + +const char* segment_name(uint32_t seg) { + return seg == kSegKUSEG ? "KUSEG" : seg == kSegKSEG0 ? "KSEG0" : "KSEG1"; +} } // namespace uint32_t decode_analysis_guard_bytes(const PS1ExeHeader& header, @@ -39,8 +52,10 @@ bool apply_static_analysis_bound(PS1Executable& exe, uint32_t configured_size, std::string& error_msg) { const uint32_t image_size = exe.header.file_size; + // Bounded by the link segment's RAM window (physical, so KUSEG, KSEG0 + // and KSEG1 images get the same 8 MiB). const uint64_t configured_end = - static_cast(exe.header.load_address) + configured_size; + static_cast(exe.header.load_address & kPhysMask) + configured_size; if (configured_size == 0) { error_msg = "game.text_size must be nonzero"; @@ -51,7 +66,7 @@ bool apply_static_analysis_bound(PS1Executable& exe, "game.text_size 0x{:X} is not instruction-aligned", configured_size); return false; } - if (configured_end > kRamWindowEnd) { + if (configured_end > kRamWindowBytes) { error_msg = fmt::format( "game.text_size 0x{:X} extends past PS1 RAM", configured_size); return false; @@ -120,10 +135,15 @@ bool PS1ExeParser::validate_header(const PS1ExeHeader& header, std::string& erro return false; } - // Check load address is in KSEG0 (cached kernel segment) - if (header.load_address < 0x80000000 || header.load_address >= 0xA0000000) { + // The load address must lie in the main-RAM window of KUSEG, KSEG0 or + // KSEG1. The header's segment is the link segment: the program runs at + // those PCs, and the recompiler compiles it there (§5.3). + const uint32_t load_seg = ram_window_segment(header.load_address); + if (load_seg == kNoSegment) { error_msg = fmt::format( - "Invalid load address 0x{:08X}. Must be in KSEG0 (0x80000000-0x9FFFFFFF)", + "Invalid load address 0x{:08X}. Must be in main RAM: KUSEG " + "0x00000000-0x007FFFFF, KSEG0 0x80000000-0x807FFFFF or KSEG1 " + "0xA0000000-0xA07FFFFF", header.load_address ); return false; @@ -146,22 +166,35 @@ bool PS1ExeParser::validate_header(const PS1ExeHeader& header, std::string& erro // Check load region stays inside the main-RAM decode window const uint64_t end_address = static_cast(header.load_address) + header.file_size; - if (end_address > kRamWindowEnd) { + if ((header.load_address & kPhysMask) + static_cast(header.file_size) > + kRamWindowBytes) { error_msg = fmt::format( - "Load region overflows RAM: 0x{:08X}-0x{:08X} (beyond 0x80800000)", - header.load_address, end_address + "Load region overflows RAM: 0x{:08X}-0x{:08X} (beyond 0x{:08X})", + header.load_address, end_address, load_seg | kRamWindowBytes ); return false; } - // Check entry point is valid - if (header.initial_pc < 0x80000000 || header.initial_pc >= 0xA0000000) { + // The entry point must be main RAM in the same segment: one link segment + // per image, and direct control flow never changes segment (§2). + const uint32_t entry_seg = ram_window_segment(header.initial_pc); + if (entry_seg == kNoSegment) { error_msg = fmt::format( - "Invalid entry point 0x{:08X}. Must be in KSEG0", + "Invalid entry point 0x{:08X}. Must be in main RAM (KUSEG, KSEG0 " + "or KSEG1)", header.initial_pc ); return false; } + if (entry_seg != load_seg) { + error_msg = fmt::format( + "Entry point 0x{:08X} ({}) and load address 0x{:08X} ({}) are in " + "different segments; an image has one link segment", + header.initial_pc, segment_name(entry_seg), + header.load_address, segment_name(load_seg) + ); + return false; + } // Warn if entry point is outside loaded range (may be overlay) if (!header.entry_in_range()) { @@ -196,22 +229,24 @@ bool PS1Executable::validate(std::string& error_msg) const { // while declaring BSS at the end of the meaningful bytes. Loading those // zero padding bytes and then clearing them as BSS is equivalent. Reject // every overlap that contains actual code/data. + // Compared physically: the header may name BSS in another segment than + // the code (the parser keeps every field as the header wrote it). if (header.memfill_size > 0) { - uint32_t bss_start = header.memfill_start; - uint32_t bss_end = header.bss_end(); - uint32_t code_end = end_address(); + uint32_t bss_start = header.memfill_start & kPhysMask; + uint32_t code_end = end_address() & kPhysMask; if (bss_start < code_end) { - if (bss_start < load_address()) { + if (bss_start < (load_address() & kPhysMask)) { error_msg = fmt::format( "BSS section [0x{:08X}, 0x{:08X}) begins before code " "[0x{:08X}, 0x{:08X})", - bss_start, bss_end, load_address(), code_end + header.memfill_start, header.bss_end(), load_address(), + end_address() ); return false; } const size_t overlap_offset = - static_cast(bss_start - load_address()); + static_cast(bss_start - (load_address() & kPhysMask)); const bool has_nonzero_payload = std::any_of( code_data.begin() + overlap_offset, code_data.end(), [](uint8_t byte) { return byte != 0; }); @@ -219,7 +254,8 @@ bool PS1Executable::validate(std::string& error_msg) const { error_msg = fmt::format( "BSS section [0x{:08X}, 0x{:08X}) overlaps non-zero " "code/data [0x{:08X}, 0x{:08X})", - bss_start, bss_end, load_address(), code_end + header.memfill_start, header.bss_end(), load_address(), + end_address() ); return false; } @@ -283,22 +319,13 @@ std::optional PS1ExeParser::parse_buffer( // Copy header (first 2048 bytes) std::memcpy(&exe.header, buffer.data(), sizeof(PS1ExeHeader)); - // Some EXEs (e.g. Kula World SCES-01000) store KUSEG addresses - // (0x00011000) instead of KSEG0 (0x80011000). KUSEG 0x0-0x1FFFFFFF - // mirrors the same physical RAM, so normalize to KSEG0 before - // validation. Only remap addresses inside the main-RAM decode window - // (every retail mirror, and the expanded 8 MiB map); 0 stays 0 (unused - // fields like initial_gp). - auto to_kseg0 = [](uint32_t& addr) { - if (addr != 0 && addr < kRamWindowBytes) addr |= 0x80000000; - }; - to_kseg0(exe.header.initial_pc); - to_kseg0(exe.header.initial_gp); - to_kseg0(exe.header.load_address); - to_kseg0(exe.header.memfill_start); - to_kseg0(exe.header.initial_sp); - to_kseg0(exe.header.initial_fp); - to_kseg0(exe.header.stack_base); + // The header's addresses are kept as written. Some EXEs (Kula World + // SCES-01000, Alien Resurrection SLUS-00633) carry KUSEG addresses + // (0x00011000) instead of KSEG0 ones (0x80011000): the BIOS jumps to the + // KUSEG entry and the program runs at KUSEG PCs, so its links, EPCs and + // I-cache tags are KUSEG. Folding them to KSEG0 compiled the program for + // a segment it never runs in (docs/SEGMENT_AWARE_CODE.md §3.2, §5.3). + // link_segment() names the segment; physical helpers do byte addressing. // Validate header if (!validate_header(exe.header, error_msg)) { diff --git a/recompiler/tests/emitter_runtime_pc_test.cpp b/recompiler/tests/emitter_runtime_pc_test.cpp index a3629dd8..14644a9e 100644 --- a/recompiler/tests/emitter_runtime_pc_test.cpp +++ b/recompiler/tests/emitter_runtime_pc_test.cpp @@ -33,12 +33,16 @@ // address. The syscall form follows PSX_CPS at process start, so ctest runs // this binary twice (emitter_runtime_pc_test and _legacy). // -// Not covered here: the game dispatch rows main_psx.cpp writes. Until PR C -// sets a non-KSEG0 code segment for a real executable they equal the compile -// address; C's ledger checks query them. +// Game dispatch rows (game_dispatch_emitter.cpp, PR C): each row's key and +// resume PC go through runtime_pc() while its func_ name keeps the compile +// identity, and the emitted lookup requires the exact PC. A real executable +// 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. #include "code_generator.h" #include "control_flow.h" +#include "game_dispatch_emitter.h" #include "mips_decoder.h" #include "strict_translator.h" @@ -191,8 +195,13 @@ constexpr uint32_t kPersistSite = 0x14u; // sb // unreachable; data and undecodable words become stubs. const std::set kNoFetch = {0x48u, 0x4Cu, 0xACu, 0xB0u, 0xB4u, 0xB8u}; +struct Compiled { + std::string code; // every generated function + std::string dispatch; // the game dispatch source +}; + // seg_override < 0: leave the code segment at its default. -std::string generate(uint32_t image_seg, long long seg_override, Mode mode) { +Compiled generate_all(uint32_t image_seg, long long seg_override, Mode mode) { set_env("PSX_CPS", mode == Mode::Legacy ? "0" : "1"); const uint32_t base = image_seg | kPhys; PSXRecomp::PS1Executable exe{}; @@ -235,11 +244,27 @@ std::string generate(uint32_t image_seg, long long seg_override, Mode mode) { for (const auto& f : funcs) if (!f.is_data_section) cfgs[f.start_addr] = analyzer.analyze_function(f); - std::string out; - for (const auto& gf : generator.generate_all_functions(funcs, cfgs)) out += gf.full_code; + Compiled out; + std::set dispatch_addrs; + for (const auto& gf : generator.generate_all_functions(funcs, cfgs)) { + out.code += gf.full_code; + // main_psx.cpp's rule: dispatchable func_ entries only. + if (gf.dispatchable && gf.function_name.rfind("func_", 0) == 0) + dispatch_addrs.insert(static_cast( + std::stoul(gf.function_name.substr(5, 8), nullptr, 16))); + } + std::string error; + if (!PSXRecomp::emit_game_dispatch(generator, exe, dispatch_addrs, + generator.generate_ranges_manifest(funcs, cfgs), + out.dispatch, error)) + check(false, std::string("emit_game_dispatch: ") + error); return out; } +std::string generate(uint32_t image_seg, long long seg_override, Mode mode) { + return generate_all(image_seg, seg_override, mode).code; +} + // Constants of one site class, in emission order. std::vector constants(const std::string& code, const std::string& pattern) { std::vector out; @@ -419,6 +444,86 @@ void check_game_emitter(Mode mode) { m + ": the cached KSEG0 body lost the line-leader rule"); } +// Dispatch rows: {key, resume, range index, range count, func_ owner}. +struct Row { + uint32_t addr, resume, owner; +}; +std::vector dispatch_rows(const std::string& src) { + std::vector rows; + static const std::regex re( + R"(\{0x([0-9A-F]{8})u, 0x([0-9A-F]{8})u, \d+u, \d+u, func_([0-9A-F]{8})\})"); + for (auto it = std::sregex_iterator(src.begin(), src.end(), re); + it != std::sregex_iterator(); ++it) + rows.push_back({static_cast(std::stoul((*it)[1].str(), nullptr, 16)), + static_cast(std::stoul((*it)[2].str(), nullptr, 16)), + static_cast(std::stoul((*it)[3].str(), nullptr, 16))}); + return rows; +} + +void check_dispatch_rows(Mode mode) { + const std::string m = mode_name(mode) + std::string("/dispatch"); + const std::string def = generate_all(KSEG0, -1, mode).dispatch; + check(def == generate_all(KSEG0, KSEG0, mode).dispatch, + m + ": an explicit KSEG0 code segment changed the dispatch source"); + const auto def_rows = dispatch_rows(def); + check(def_rows.size() >= 10, m + ": too few dispatch rows parsed"); + // The lookup indexes the physical word, then requires the exact PC. + check(def.find("if (!index || k_psx_game_dispatch[index - 1u].addr != addr) return 0;") != + std::string::npos, + m + ": the lookup does not require the exact PC"); + for (uint32_t seg : {KUSEG, KSEG1}) { + const std::string sn = seg == KUSEG ? "KUSEG" : "KSEG1"; + const std::string src = generate_all(KSEG0, seg, mode).dispatch; + const auto rows = dispatch_rows(src); + check(rows.size() == def_rows.size(), m + "/" + sn + ": row count changed"); + bool continuation = false; + for (size_t i = 0; i < rows.size() && i < def_rows.size(); ++i) { + const Row& r = rows[i]; + check((r.addr & 0xE0000000u) == seg, + m + "/" + sn + ": row key 0x" + hex(r.addr) + " is not in " + sn); + check(r.resume == 0 || (r.resume & 0xE0000000u) == seg, + m + "/" + sn + ": resume PC 0x" + hex(r.resume) + " is not in " + sn); + check((r.owner & 0xE0000000u) == KSEG0 && r.owner == def_rows[i].owner, + m + "/" + sn + ": func_" + hex(r.owner) + " is not the compile identity"); + check((r.addr & 0x1FFFFFFFu) == (def_rows[i].addr & 0x1FFFFFFFu), + m + "/" + sn + ": row 0x" + hex(r.addr) + " moved"); + continuation |= r.resume != 0; + } + if (mode != Mode::Legacy) + check(continuation, m + "/" + sn + ": no continuation row (resume PC) emitted"); + if (seg == KUSEG) + check(to_phys(src) == to_phys(def), + m + "/" + sn + ": dispatch differs from KSEG0 beyond constant segments"); + } +} + +// 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. +void check_dispatch_duplicate_word() { + const uint32_t base = KSEG0 | kPhys; + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = base; + exe.header.initial_pc = base; + exe.header.file_size = kCount * 4u; + for (uint32_t w : kProgram) append_word(exe.code_data, w); + PSXRecomp::CodeGenConfig config{}; + PSXRecomp::CodeGenerator generator(exe, config); + std::string out, error; + check(PSXRecomp::emit_game_dispatch(generator, exe, {base, base + 0x20u}, "", out, error) && + error.empty() && !out.empty(), + "dispatch: rows on distinct words must emit"); + for (uint32_t alias : {KUSEG | kPhys, KSEG1 | (kPhys + 0x20u)}) { + out.clear(); + error.clear(); + check(!PSXRecomp::emit_game_dispatch(generator, exe, {base, base + 0x20u, alias}, "", + out, error), + "dispatch: two rows on one physical word (0x" + hex(alias) + ") must be an error"); + check(error.find("share a physical word") != std::string::npos, + "dispatch: the duplicate-word error must say why, got: " + error); + } +} + void check_strict_translator(bool strict_cps) { // SCPH-1001 Kernel Part 2 runs at 0x500 from ROM 0xBFC10000: the store at // ROM 0xBFC10A00 executes at 0x00000F00 (memory.c's RAM-0 filter key). @@ -490,6 +595,9 @@ int main() { check_game_emitter(Mode::Cps); check_game_emitter(Mode::CpsOverlay); check_game_emitter(Mode::Legacy); + check_dispatch_rows(Mode::Cps); + check_dispatch_rows(Mode::Legacy); + check_dispatch_duplicate_word(); check_strict_translator(strict_cps); if (failures != 0) { std::fprintf(stderr, "emitter_runtime_pc_test: %d failure(s)\n", failures); diff --git a/recompiler/tests/reachable_discovery_test.cpp b/recompiler/tests/reachable_discovery_test.cpp index 1b12d3bf..4391d7c7 100644 --- a/recompiler/tests/reachable_discovery_test.cpp +++ b/recompiler/tests/reachable_discovery_test.cpp @@ -831,15 +831,48 @@ int main() { CHECK(!PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error).has_value() && mirror_error.find("beyond 0x80800000") != std::string::npos, "image crossing the RAM decode window end is rejected"); - // A KUSEG header anywhere in the window names the same RAM as KSEG0. + // A KUSEG header names the same RAM as KSEG0 but keeps its segment: the + // program runs at KUSEG PCs, so it compiles there + // (docs/SEGMENT_AWARE_CODE.md §5.3). put32(mirror_image, 0x10, 0x00780000u); put32(mirror_image, 0x18, 0x00780000u); { auto kuseg = PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error); - CHECK(kuseg.has_value() && kuseg->load_address() == 0x80780000u && - kuseg->header.initial_pc == 0x80780000u, - "KUSEG mirror-window header addresses normalize to KSEG0"); + CHECK(kuseg.has_value() && kuseg->load_address() == 0x00780000u && + kuseg->header.initial_pc == 0x00780000u && + kuseg->link_segment() == PSXRecomp::kSegKUSEG && + kuseg->phys_load_address() == 0x00780000u && + kuseg->contains_phys(0x80780FFCu) && + !kuseg->contains_phys(0x00781000u) && + kuseg->read_word(0x00780000u).has_value() && + !kuseg->read_word(0x80780000u).has_value(), + "a KUSEG header keeps its segment; bytes are addressed physically"); } + put32(mirror_image, 0x10, 0x007FF800u); + put32(mirror_image, 0x18, 0x007FF800u); + CHECK(!PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error).has_value() && + mirror_error.find("beyond 0x00800000") != std::string::npos, + "a KUSEG image crossing its RAM window end is rejected"); + // KSEG1 is accepted as an uncached home segment. + put32(mirror_image, 0x10, 0xA0010000u); + put32(mirror_image, 0x18, 0xA0010000u); + { + auto kseg1 = PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error); + CHECK(kseg1.has_value() && kseg1->link_segment() == PSXRecomp::kSegKSEG1, + "a KSEG1-linked header is accepted in its own segment"); + } + // One image, one link segment. + put32(mirror_image, 0x10, 0x80010000u); + put32(mirror_image, 0x18, 0x00010000u); + CHECK(!PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error).has_value() && + mirror_error.find("different segments") != std::string::npos, + "an entry in another segment than the load address is rejected"); + // Outside main RAM (KSEG2, the BIOS ROM) is not a PS-X EXE target. + put32(mirror_image, 0x10, 0xBFC00000u); + put32(mirror_image, 0x18, 0xBFC00000u); + CHECK(!PSXRecomp::PS1ExeParser::parse_buffer(mirror_image, mirror_error).has_value() && + mirror_error.find("Must be in main RAM") != std::string::npos, + "a load address outside the main-RAM windows is rejected"); if (failures) { std::fprintf(stderr, "FAILED (%d)\n", failures); diff --git a/recompiler/tests/test_kuseg_dispatch_lookup.py b/recompiler/tests/test_kuseg_dispatch_lookup.py index afd64762..36b1e99d 100644 --- a/recompiler/tests/test_kuseg_dispatch_lookup.py +++ b/recompiler/tests/test_kuseg_dispatch_lookup.py @@ -1,27 +1,31 @@ #!/usr/bin/env python3 -"""Recompiler codegen regression test: KUSEG-addressed games must dispatch. +"""Recompiler codegen regression test: a KUSEG-linked EXE compiles for KUSEG, +and dispatch is exact (docs/SEGMENT_AWARE_CODE.md §5.3, §5.5). -PS1 segments alias the same physical RAM. Most PS-X EXE headers carry KSEG -addresses (0x8001xxxx), but a header may legally carry KUSEG ones instead -(0x0001xxxx) -- Alien Resurrection (SLUS-00633) does, and it then executes with -a KUSEG PC. +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 +addresses (0x8001xxxx); some carry KUSEG ones (0x0001xxxx: Alien Resurrection +SLUS-00633, Kula World SCES-01000), and the program then runs at KUSEG PCs. -The recompiler normalizes the dispatch table to KSEG keys. When the lookup -compared raw values, a KUSEG PC could never match: 0x0001xxxx is always below -0x8001xxxx, so the binary search collapsed to the start and returned no entry. -Every dispatch fell through to the interpreter -- silently, because the -text-image guard was never even reached (it reported zero blocks and zero -mismatches while the whole game ran interpreted). +The recompiler used to fold such a header to KSEG0 and look PCs up by physical +address, so every KUSEG PC ran a body that baked KSEG0 links and tags, and a +KSEG0 or KSEG1 alias ran it too. Now: + - the EXE parser keeps the header's segment (the link segment) and the + image compiles there: rows, names and the PCs a body bakes are KUSEG; + - the emitted lookup indexes the physical word and then requires the exact + 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; + - 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. -This test synthesizes a KUSEG-addressed PS-EXE and asserts the emitted lookup -compares 29-bit physical addresses, and that the table is ordered by that same -key so the binary-search invariant holds. - -It also pins the RAM-mirror contract against the runtime's real geometry +The RAM-mirror contract is pinned against the runtime's real geometry (runtime/src/psx_ram_geometry.c, linked in): a PC in the 2nd-4th RAM mirror is a different code address, in retail 2 MiB and 8 MiB geometry alike, so it never -resolves to a compiled body (whose $ra/EPC constants are the KSEG PCs it was -compiled for) and never counts as EXE text. +resolves to a compiled body and never counts as EXE text. Usage: python test_kuseg_dispatch_lookup.py [--recompiler ] Exit 0 = PASS. @@ -30,6 +34,10 @@ import argparse, os, re, shutil, struct, subprocess, sys, tempfile LOAD = 0x00010000 # KUSEG: no KSEG bit, the case under test PHYS_MASK = 0x1FFFFFFF +SEG_MASK = 0xE0000000 +KUSEG, KSEG0, KSEG1 = 0x00000000, 0x80000000, 0xA0000000 +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) def w(words): @@ -50,41 +58,120 @@ def jal(target): return 0x0C000000 | ((target >> 2) & 0x03FFFFFF) -def build_exe(): - # func A @ LOAD: addiu sp,-8 ; jal B ; nop ; addiu sp,8 ; jr ra ; nop - # func B @ LOAD+0x20: jr ra ; nop - a = [0x27BDFFF8, jal(LOAD + 0x20), 0x00000000, 0x27BD0008, 0x03E00008, 0x00000000] +def build_body(load): + # func A @ load: addiu sp,-8 ; sw ra,4(sp) ; jal B ; nop ; lw ra,4(sp) ; + # addiu sp,8 ; jr ra ; nop + # func B @ load+0x20: jr ra ; nop + a = [0x27BDFFF8, 0xAFBF0004, jal(load + 0x20), 0x00000000, 0x8FBF0004, + 0x27BD0008, 0x03E00008, 0x00000000] body = bytearray(w(a)) body += b"\x00" * (0x20 - len(body)) body += w([0x03E00008, 0x00000000]) - return make_psxexe(LOAD, LOAD, bytes(body)) + return bytes(body) -def gen_dispatch(recompiler, tmp): - psx = os.path.join(tmp, "t.psx") - seeds = os.path.join(tmp, "seeds.txt") - out = os.path.join(tmp, "out") +def build_jal_body(): + """Functions only a JAL scan finds, in a KUSEG image (§5.3). + + +00 main: jal B, then jal save with $a0 = $sp; +18 is the return from + save, a SaveState-style continuation (save stores $ra through $a0). + +28 a `j .` spin, so nothing before B ends in `jr $ra`. + +30 B: reached only by main's jal. + +3C save: sw $ra, 0($a0). + """ + return w([0x27BDFFF8, 0xAFBF0004, jal(LOAD + 0x30), 0x00000000, + jal(LOAD + 0x3C), 0x03A02025, 0x8FBF0004, 0x27BD0008, + 0x03E00008, 0x00000000, + 0x0800400A, 0x00000000, + 0x24020001, 0x03E00008, 0x00000000, + 0xAC9F0000, 0x03E00008, 0x00000000]) + + +WIDE_SPAN = 0x200000 # the emitter indexes spans below this; wider ones bsearch + + +def build_wide_body(): + """Two leaf functions WIDE_SPAN apart: the emitted lookup must take its + binary-search form (tables that span 2 MiB or more, e.g. 8 MiB titles).""" + body = bytearray(WIDE_SPAN + 0x800) + body[0:8] = w([0x03E00008, 0x00000000]) + body[WIDE_SPAN:WIDE_SPAN + 8] = w([0x03E00008, 0x00000000]) + return bytes(body) + + +def lookup_fragment(src): + """psx_game_address_in_text .. psx_game_is_function_entry, and the func_ + names it references (bound to a fixture double by the harness).""" + begin = src.index("int psx_game_address_in_text(uint32_t addr) {") + entry_probe = src.index("int psx_game_is_function_entry(uint32_t addr) {", begin) + end = src.index("}\n", entry_probe) + 2 + fragment = src[begin:end] + return fragment, sorted(set(re.findall(r"func_[0-9A-Fa-f]{8}", fragment))) + + +def compile_and_run(compiler, harness, sources, include): + with tempfile.TemporaryDirectory() as tmp: + source = os.path.join(tmp, "lookup.c") + binary = os.path.join(tmp, "lookup.exe" if os.name == "nt" else "lookup") + with open(source, "w", encoding="utf-8") as f: + f.write(harness) + compiler_name = os.path.basename(compiler).lower() + if compiler_name in ("cl", "cl.exe", "clang-cl", "clang-cl.exe"): + command = [compiler, "/nologo", "/Od", "/I" + include, source] + sources + \ + ["/Fe:" + binary] + else: + command = [compiler, "-std=c11", "-O2", "-I", include, source] + sources + \ + ["-o", binary] + subprocess.run(command, check=True, cwd=tmp) + subprocess.run([binary], check=True, cwd=tmp) + + +def run(recompiler, args, cwd=ROOT): + return subprocess.run([recompiler] + args, capture_output=True, text=True, cwd=cwd) + + +def generate(recompiler, tmp, name, entry, load, seeds, body=None): + psx = os.path.join(tmp, name + ".psx") + seed_path = os.path.join(tmp, name + ".seeds.txt") + out = os.path.join(tmp, name) os.makedirs(out, exist_ok=True) with open(psx, "wb") as f: - f.write(build_exe()) - with open(seeds, "w") as f: - f.write("0x%08X\n0x%08X\n" % (LOAD, LOAD + 0x20)) - r = subprocess.run([recompiler, psx, "--seeds", seeds, "--out-dir", out], - capture_output=True, text=True) + f.write(make_psxexe(entry, load, body if body is not None else build_body(load))) + with open(seed_path, "w") as f: + f.write("".join("0x%08X\n" % s for s in seeds)) + r = run(recompiler, [psx, "--seeds", seed_path, "--out-dir", out]) if r.returncode != 0: - raise SystemExit("recompiler failed:\n" + (r.stderr or r.stdout)) + raise SystemExit("recompiler failed on %s:\n%s" % (name, r.stderr or r.stdout)) disp = [f for f in os.listdir(out) if f.endswith("_dispatch.c")] if not disp: raise SystemExit("no _dispatch.c emitted in " + out) with open(os.path.join(out, disp[0])) as f: - return f.read() + dsrc = f.read() + full = "" + for n in sorted(os.listdir(out)): + if re.match(r".*_full(_\d+)?\.c$", n): + with open(os.path.join(out, n)) as f: + full += f.read() + return dsrc, full, r.stdout + + +def rows_of(src): + tbl = re.search(r"k_psx_game_dispatch\[\] = \{(.*?)\n\};", src, re.DOTALL) + if not tbl: + raise SystemExit("dispatch table not found in emitted dispatch") + return [(int(a, 16), int(r, 16), fn) for a, r, fn in re.findall( + r"\{0x([0-9A-F]{8})u, 0x([0-9A-F]{8})u, \d+u, \d+u, (func_[0-9A-F]{8})\}", tbl.group(1))] + + +def check(cond, msg): + if not cond: + raise SystemExit("FAIL: " + msg) def main(): - here = os.path.dirname(os.path.abspath(__file__)) ap = argparse.ArgumentParser() ap.add_argument("--recompiler", - default=os.path.normpath(os.path.join(here, "..", "build", + default=os.path.normpath(os.path.join(HERE, "..", "build", "psxrecomp-game"))) ap.add_argument("--compiler", default=os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc")) args = ap.parse_args() @@ -92,47 +179,177 @@ def main(): raise SystemExit("recompiler not found: %s (build it first)" % args.recompiler) with tempfile.TemporaryDirectory() as tmp: - src = gen_dispatch(args.recompiler, tmp) + # -- the KUSEG image: seeds in the link segment, plus one KSEG0 seed --- + src, full, log = generate(args.recompiler, tmp, "kuseg", LOAD, LOAD, + [LOAD, LOAD + 0x20, KSEG0 | (LOAD + 0x20)]) + 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") + 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({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") + keys = [a & PHYS_MASK for a, _, _ in rows] + check(keys == sorted(keys), "dispatch table is sorted by physical address") - m = re.search(r"static const PsxGameDispatchEntry\* psx_game_find_entry" - r"\(uint32_t addr\) \{(.*?)\n\}", src, re.DOTALL) - if not m: - raise SystemExit("psx_game_find_entry not found in emitted dispatch") - body = m.group(1) + m = re.search(r"static const PsxGameDispatchEntry\* psx_game_find_entry" + r"\(uint32_t addr\) \{(.*?)\n\}", src, re.DOTALL) + check(m, "psx_game_find_entry not found in emitted dispatch") + 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") - # The incoming PC must be reduced to a physical address before comparing. - if "0x1FFFFFFFu" not in body: - raise SystemExit( - "psx_game_find_entry does not mask to a physical address; a KUSEG " - "guest PC can never match KSEG-normalized table keys") - if "k_psx_game_dispatch_index" not in body: - raise SystemExit("small resident tables must use the immutable word index") + # -- 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()) + 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) + 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") - # The binary search requires the table to be ordered by the same key it - # compares. Parse the emitted entries and assert monotonic physical order. - tbl = re.search(r"k_psx_game_dispatch\[\] = \{(.*?)\n\};", src, re.DOTALL) - if not tbl: - raise SystemExit("dispatch table not found in emitted dispatch") - keys = [int(x, 16) & PHYS_MASK - for x in re.findall(r"\{0x([0-9A-Fa-f]{8})u,", tbl.group(1))] - if len(keys) < 2: - raise SystemExit("expected at least two dispatch entries, got %d" % len(keys)) - if keys != sorted(keys): - raise SystemExit("dispatch table is not sorted by physical address; " - "the masked binary search would miss entries") + # -- JAL-driven discovery takes the call's segment, not KSEG0 -------- + jsrc, _, jlog = generate(args.recompiler, tmp, "kuseg_jal", LOAD, LOAD, [], + body=build_jal_body()) + jrows = {a: (r, fn) for a, r, fn in rows_of(jsrc)} + check(jrows.get(LOAD + 0x30) == (0, "func_00010030"), + "a function reached only by jal is discovered in a KUSEG image: %s" % jrows) + check(jrows.get(LOAD + 0x18) == (0, "func_00010018"), + "the SaveState-style continuation after jal save is an entry: %s" % jrows) + check("Identified 1 SaveState continuation entries" in jlog, + "the SaveState scan follows the KUSEG jal") + + # -- a header whose entry is in another segment than its load address + psx = os.path.join(tmp, "mixed.psx") + with open(psx, "wb") as f: + f.write(make_psxexe(KSEG0 | LOAD, LOAD, build_body(LOAD))) + r = run(args.recompiler, [psx, "--out-dir", os.path.join(tmp, "mixed")]) + check(r.returncode != 0 and "different segments" in (r.stdout + r.stderr), + "an entry and load address in different segments must be rejected") + + # -- a KSEG1-linked image: uncached home segment ----------------------- + _, k1_full, _ = generate(args.recompiler, tmp, "kseg1", KSEG1 | LOAD, KSEG1 | LOAD, + [KSEG1 | LOAD, KSEG1 | (LOAD + 0x20)]) + body_a = re.search(r"void func_A0010000\(CPUState\* cpu\)\s*\{(.*?)^\}", k1_full, + re.M | re.S) + check(body_a, "the KSEG1 image's entry is named func_A0010000") + insns = re.findall(r"/\* 0x([0-9A-F]{8}): ", body_a.group(1)) + fetched = set(re.findall(r"psx_icache_fetch\(cpu, 0x([0-9A-F]{8})u\)", body_a.group(1))) + check(insns and all(i in fetched for i in insns), + "every KSEG1 instruction is charged its own fetch (§5.6)") + + # -- config code sites are code identities in the link segment --------- + exe = os.path.join(tmp, "kuseg.psx") + for site, ok in ((KSEG0 | (LOAD + 0x20), False), (LOAD + 0x20, True)): + cfg = os.path.join(tmp, "game_%08X.toml" % site) + # TOML literal strings: a Windows path's backslashes are not escapes. + with open(cfg, "w") as f: + f.write("[game]\nname = 't'\nexe = '%s'\ntext_size = '0x%X'\n" + "[recompiler]\nseeds = '%s'\nout_dir = '%s'\n" + "hot_funcs = ['0x%08X']\n" + % (exe, len(build_body(LOAD)), os.path.join(tmp, "kuseg.seeds.txt"), + os.path.join(tmp, "cfg_%08X" % site), site)) + r = run(args.recompiler, ["--config", cfg, "--project-root", ROOT]) + if ok: + check(r.returncode == 0, "a link-segment config site is accepted:\n%s" + % (r.stderr or r.stdout)[-2000:]) + else: + check(r.returncode != 0 and + "recompiler.hot_funcs: 0x80010020 -> 0x00010020" in r.stderr, + "a KSEG0 config site for a KUSEG image must be refused:\n%s" + % (r.stderr or r.stdout)[-2000:]) + + # load_accel return PCs are compared exactly with $ra by the runtime. + cfg = os.path.join(tmp, "game_horizon.toml") + with open(cfg, "w") as f: + f.write("[game]\nname = 't'\nexe = '%s'\ntext_size = '0x%X'\n" + "[recompiler]\nseeds = '%s'\nout_dir = '%s'\n" + "[load_accel.vsync_query]\nfunc = '0x%08X'\n" + "counter_addr = '0x80090000'\ngpustat_ptr_addr = '0x80090004'\n" + "timer1_ptr_addr = '0x80090008'\ntimer1_cache_addr = '0x8009000C'\n" + "event_horizon_extra_sites = ['0x%08X']\n" + % (exe, len(build_body(LOAD)), os.path.join(tmp, "kuseg.seeds.txt"), + os.path.join(tmp, "cfg_horizon"), LOAD, KSEG0 | (LOAD + 0x10))) + r = run(args.recompiler, ["--config", cfg, "--project-root", ROOT]) + check(r.returncode != 0 and + "load_accel.vsync_query.event_horizon_extra_sites: 0x80010010 -> 0x00010010" + in r.stderr, + "a KSEG0 event-horizon return PC for a KUSEG image must be refused:\n%s" + % (r.stderr or r.stdout)[-2000:]) + + # A kind the emitter matches by physical address (a full-word-guarded + # substitution, like a byte patch) reaches the same bytes in any + # spelling, so it is not refused, and it still applies. + lui_exe = os.path.join(tmp, "kuseg_lui.psx") + with open(lui_exe, "wb") as f: + f.write(make_psxexe(LOAD, LOAD, w([0x3C020140, 0x03E00008, 0x00000000]))) + lui_seeds = os.path.join(tmp, "kuseg_lui.seeds.txt") + with open(lui_seeds, "w") as f: + f.write("0x%08X\n" % LOAD) + cfg = os.path.join(tmp, "game_bound.toml") + with open(cfg, "w") as f: + f.write("[game]\nname = 't'\nexe = '%s'\ntext_size = '0xC'\n" + "[recompiler]\nseeds = '%s'\nout_dir = '%s'\n" + "[[widescreen.signed_x_bound]]\naddress = '0x%08X'\n" + "expected = '0x3C020140'\n" + % (lui_exe, lui_seeds, os.path.join(tmp, "cfg_bound"), KSEG0 | LOAD)) + r = run(args.recompiler, ["--config", cfg, "--project-root", ROOT]) + check(r.returncode == 0, "a physically matched config site is accepted in any " + "segment:\n%s" % (r.stderr or r.stdout)[-2000:]) + bound_c = "" + for n in os.listdir(os.path.join(tmp, "cfg_bound")): + if n.endswith(".c"): + with open(os.path.join(tmp, "cfg_bound", n)) as f: + bound_c += f.read() + check("psx_ws_player_x_bound((int32_t)0x01400000)" in bound_c, + "the physically matched signed_x_bound site still applies") + + # Seeds-file directives name code by PC too: a foreign spelling is + # refused with the link-segment one, not a bare "invalid" message. + for directive, want in ( + ("producer_range 0x80010000 0x80010028", + "write its addresses as: 0x00010000 0x00010028"), + ("cross_call_allow 0x80010020", "write its addresses as: 0x00010020"), + ("hosted_interior 0x80010010 0x00010000", + "write its addresses as: 0x00010010 0x00010000")): + seeds = os.path.join(tmp, "directive.seeds.txt") + with open(seeds, "w") as f: + f.write("0x%08X\n%s\n" % (LOAD, directive)) + r = run(args.recompiler, [exe, "--seeds", seeds, "--out-dir", + os.path.join(tmp, "directive")]) + check(r.returncode != 0 and want in r.stderr, + "a KSEG0 %r for a KUSEG image must be refused with its KUSEG " + "spelling:\n%s" % (directive, (r.stderr or r.stdout)[-2000:])) # Compile the REAL emitted lookup, validity gates and dispatch function, # linked against the runtime's REAL live-geometry state machine - # (runtime/src/psx_ram_geometry.c + psx_memory.h), so the mirror contract - # below is checked against exactly what the runtime links -- no copies. - # Only peripheral callbacks and generated MIPS bodies are fixture doubles. + # (runtime/src/psx_ram_geometry.c + psx_memory.h), so the contracts below + # are checked against exactly what the runtime links -- no copies. Only + # peripheral callbacks and generated MIPS bodies are fixture doubles. if not args.compiler: raise SystemExit("a C compiler is required for the dispatch regression") - runtime = os.path.normpath(os.path.join(here, "..", "..", "runtime")) - begin = src.index("int psx_game_address_in_text(uint32_t addr) {") - end = src.index("/* 1 iff addr is a re-enterable", begin) - fragment = src[begin:end] - funcs = sorted(set(re.findall(r"func_[0-9A-Fa-f]{8}", fragment))) + runtime = os.path.join(ROOT, "runtime") + include = os.path.join(runtime, "include") + fragment, funcs = lookup_fragment(src) harness = r""" #include #include @@ -164,23 +381,42 @@ static void set_geometry(int expanded) { } int main(void) { - const uint32_t aliases[] = {0, 0x80000000u, 0xA0000000u}; + const uint32_t segments[] = {0x00000000u, 0x80000000u, 0xA0000000u}; CPUState cpu = {0}; + unsigned home_hits = 0; /* Retail (8 MB mod off) first, then the expanded map: code identity is the - * segment-stripped PC in BOTH, so geometry never changes which compiled - * body a PC may run. */ + * full PC in BOTH, so geometry never changes which compiled body a PC may + * run. */ for (int expanded = 0; expanded < 2; ++expanded) { set_geometry(expanded); - for (unsigned a = 0; a < 3; ++a) { + for (unsigned s = 0; s < 3; ++s) { for (uint32_t phys = 0xFFF0u; phys < 0x10080u; ++phys) { + uint32_t addr = segments[s] | phys; + /* Exact: only a row compiled for this very PC answers. The + * image is KUSEG-linked, so its KSEG0 and KSEG1 aliases have + * no body and miss (a segment miss, interpreted by the + * runtime), while text membership stays physical. */ const PsxGameDispatchEntry* expected = 0; for (unsigned i = 0; i < PSX_GAME_DISPATCH_COUNT; ++i) - if ((k_psx_game_dispatch[i].addr & 0x1FFFFFFFu) == phys) + if (k_psx_game_dispatch[i].addr == addr) expected = &k_psx_game_dispatch[i]; - uint32_t addr = aliases[a] | phys; assert(psx_game_find_entry(addr) == expected); + assert(psx_game_is_function_entry(addr) == (expected != 0)); + if (segments[s] != 0x00000000u) assert(!expected); + 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) { + int old_calls = calls, old_irqs = irqs; + cpu.pc = 0xDEADBEEFu; + assert(!psx_game_text_native_ok(addr)); + assert(!psx_game_text_native_ok_full(addr)); + assert(!psx_dispatch_game_compiled(&cpu, addr)); + assert(calls == old_calls && irqs == old_irqs && cpu.pc == 0xDEADBEEFu); + } /* A PC in a 2nd-4th RAM mirror keeps its own address: the - * compiled body bakes the KSEG PCs it was compiled for into + * compiled body bakes the PCs it was compiled for into * $ra/EPC, so it must never run for a mirror PC, and the * mirror is not the EXE text. (Retail executes the folded * bytes through the interpreter, with the mirror PC.) */ @@ -195,8 +431,8 @@ int main(void) { assert(calls == old_calls && irqs == old_irqs && cpu.pc == 0xDEADBEEFu); } - if (expected) assert(psx_game_address_in_text(addr)); if (!expected) continue; + home_hits++; // A previously resolved PC must NOT cache its live-byte verdict. allowed = 0; cpu.pc = 0xDEADBEEFu; int old_calls = calls, old_irqs = irqs; @@ -212,28 +448,69 @@ int main(void) { assert(!psx_game_find_entry(0xFFFFFFFFu)); assert(!psx_game_find_entry(0)); } + assert(home_hits >= 4); /* both functions, both geometries */ return 0; } """ - with tempfile.TemporaryDirectory() as tmp: - source = os.path.join(tmp, "lookup.c") - geometry = os.path.join(runtime, "src", "psx_ram_geometry.c") - include = os.path.join(runtime, "include") - binary = os.path.join(tmp, "lookup.exe" if os.name == "nt" else "lookup") - with open(source, "w", encoding="utf-8") as f: - f.write(harness) - compiler_name = os.path.basename(args.compiler).lower() - if compiler_name in ("cl", "cl.exe", "clang-cl", "clang-cl.exe"): - command = [args.compiler, "/nologo", "/Od", "/I" + include, source, geometry, - "/Fe:" + binary] - else: - command = [args.compiler, "-std=c11", "-O2", "-I", include, source, geometry, - "-o", binary] - subprocess.run(command, check=True, cwd=tmp) - subprocess.run([binary], check=True, cwd=tmp) + compile_and_run(args.compiler, harness, + [os.path.join(runtime, "src", "psx_ram_geometry.c")], include) - print("KUSEG dispatch lookup test passed (%d entries, physical-keyed, " - "mirror PCs never run compiled code in 2 or 8 MiB geometry)" % len(keys)) + # The binary-search form of the same lookup (a table spanning 2 MiB or + # more): every row answers only for its exact PC, in every segment; the + # other segments' aliases and the words next to a row miss and never run. + wide_fragment, wide_funcs = lookup_fragment(wide_src) + wide = r""" +#include +#include +#include +typedef struct { uint32_t pc; } CPUState; +static int calls = 0; +static void dummy(CPUState* cpu) { (void)cpu; calls++; } +static int dirty_ram_text_native_ok_ranges_from(const uint32_t* r, uint32_t n, uint32_t a) { + (void)r; (void)n; (void)a; return 1; +} +static int dirty_ram_text_native_ok_ranges(const uint32_t* r, uint32_t n) { + return dirty_ram_text_native_ok_ranges_from(r, n, 0); +} +static void psx_check_interrupts_dispatch_entry(CPUState* cpu, uint32_t a) { (void)cpu; (void)a; } +int psx_vsync_query_hle_try(CPUState* cpu, uint32_t a) { (void)cpu; (void)a; return 0; } +""" + wide += "\n".join(f"#define {fn} dummy" for fn in wide_funcs) + "\n" + wide_fragment + wide += r""" +int main(void) { + const uint32_t segments[] = {0x00000000u, 0x80000000u, 0xA0000000u}; + CPUState cpu = {0}; + unsigned hits = 0; + 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; + 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); + const PsxGameDispatchEntry* expected = 0; + for (unsigned j = 0; j < PSX_GAME_DISPATCH_COUNT; ++j) + 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); + const int before = calls; + assert(psx_dispatch_game_compiled(&cpu, addr) == (expected != 0)); + assert(calls == before + (expected != 0)); + hits += expected != 0; + } + } + } + assert(hits == PSX_GAME_DISPATCH_COUNT); + return 0; +} +""" + 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)) if __name__ == "__main__": diff --git a/recompiler/tests/test_segment_aware_codegen.py b/recompiler/tests/test_segment_aware_codegen.py index 5b0a1bbc..f77f74e0 100644 --- a/recompiler/tests/test_segment_aware_codegen.py +++ b/recompiler/tests/test_segment_aware_codegen.py @@ -46,15 +46,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 = { - "link-segment": ("5.3", "jal/jalr/bgezal links carry KSEG0, not the EXE's link segment"), - "fetch-tag-segment": ("5.3", "I-cache fetch tags carry KSEG0, not the link segment"), - "irq-resume-segment": ("5.3", "psx_check_interrupts_at resume PCs (future EPCs) carry KSEG0"), - "resume-pc-segment": ("5.3", "CPS exit PCs and continuation keys carry KSEG0"), - "store-pc-segment": ("5.3", "store-PC stamps (memory.c store filters read them) carry KSEG0"), - "home-seed-accepted": ("5.3", "a seed written in the EXE's own KUSEG segment is dropped"), - "alias-fetch-coherence": ("5.3", "a compiled body misses I-cache lines the interpreter filled at the real PC"), "segment-variants": ("5.4", "a seeded KSEG1 entry gets no body of its own"), - "segment-miss": ("5.5", "an alias PC with no body of its own resolves to another segment's body"), "kseg1-fetch-charge": ("5.6", "compiled KSEG1 code does not charge +4 per fetch"), } @@ -64,6 +56,16 @@ REGRESSION_GUARDS = { "bios-kseg1-fetch-charge": "5.6", # PR A (#429) "bios-runtime-pc": "5.2", # PR B: BIOS PCs handed to the runtime "store-pc-keys-runtime": "9", # PR B: memory.c keys re-keyed + # PR C: a KUSEG-linked EXE compiles for its link segment, and dispatch + # is exact. + "link-segment": "5.3", + "fetch-tag-segment": "5.3", + "irq-resume-segment": "5.3", + "resume-pc-segment": "5.3", + "store-pc-segment": "5.3", + "home-seed-accepted": "5.3", + "alias-fetch-coherence": "5.3", + "segment-miss": "5.5", } @@ -441,6 +443,11 @@ def main(): for gid, pattern in PC_SITES.items(): off = sorted({pc for fn in home_bodies for pc in pc_constants(bodies[fn], pattern) 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}) if off: gaps.add(gid) notes[gid] = "%d constants, e.g. 0x%08X" % (len(off), off[0]) @@ -563,7 +570,7 @@ def main(): known = set(KNOWN_GAPS) print("segment-aware codegen ledger (docs/SEGMENT_AWARE_CODE.md):") - for gid in sorted(known | gaps): + for gid in sorted(known | gaps | set(REGRESSION_GUARDS)): state = ("gap" if gid in gaps else "closed") sec = KNOWN_GAPS.get(gid, (REGRESSION_GUARDS.get(gid, "?"), ""))[0] print(" %-24s %-6s section %-4s %s" % (gid, state, sec, notes.get(gid, ""))) diff --git a/runtime/tests/test_mirror_pc_preserved_guards.py b/runtime/tests/test_mirror_pc_preserved_guards.py index be48a912..6ba114b4 100644 --- a/runtime/tests/test_mirror_pc_preserved_guards.py +++ b/runtime/tests/test_mirror_pc_preserved_guards.py @@ -22,7 +22,7 @@ ROOT = Path(__file__).resolve().parents[2] INTERP = (ROOT / 'runtime/src/dirty_ram_interp.c').read_text(encoding='utf-8') MEMORY = (ROOT / 'runtime/src/memory.c').read_text(encoding='utf-8') EMITTER = (ROOT / 'recompiler/src/full_function_emitter.cpp').read_text(encoding='utf-8') -GAME = (ROOT / 'recompiler/src/main_psx.cpp').read_text(encoding='utf-8') +GAME = (ROOT / 'recompiler/src/game_dispatch_emitter.cpp').read_text(encoding='utf-8') def body(source, signature): diff --git a/tools/collect_game_misses.py b/tools/collect_game_misses.py index f49ed3d3..312d32fa 100644 --- a/tools/collect_game_misses.py +++ b/tools/collect_game_misses.py @@ -1,61 +1,170 @@ """Collect dirty_ram dispatch misses in the game text range and append -new unique addresses to a persistent log file. Run this during gameplay +new unique addresses to a persistent seeds file. Run this during gameplay to build up the seed list over time. +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). + +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 +a KSEG0/KSEG1 address. A physical game_text_lo alone cannot tell KUSEG from +KSEG0, so the tool refuses to guess. + Usage: python3 collect_game_misses.py -Example (Tomba): python3 collect_game_misses.py 4470 0x10000 0x98000 seeds/dirty_ram_misses.txt + [--segment kuseg|kseg0|kseg1] [--game-toml PATH] +Example (Tomba): python3 collect_game_misses.py 4470 0x80010000 0x80098000 seeds/dirty_ram_misses.txt +Example (KUSEG): python3 collect_game_misses.py 4720 0x10000 0x98000 seeds/misses.txt --game-toml game.toml """ -import socket, json, sys, os +import argparse +import json +import os +import socket +import struct +import sys -port = int(sys.argv[1], 0) if len(sys.argv) > 1 else 4470 -lo = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x10000 -hi = int(sys.argv[3], 0) if len(sys.argv) > 3 else 0x98000 -out_file = sys.argv[4] if len(sys.argv) > 4 else 'dirty_ram_misses.txt' +try: + import tomllib +except ModuleNotFoundError: # Python < 3.11 + try: + import tomli as tomllib + except ModuleNotFoundError: + tomllib = None -def send(cmd, **kw): - s = socket.create_connection(('127.0.0.1', port), timeout=5) - s.sendall(json.dumps({'id':1,'cmd':cmd,**kw}).encode() + b'\n') - d = b'' +PHYS_MASK = 0x1FFFFFFF +SEG_MASK = 0xE0000000 +SEGMENTS = {"kuseg": 0x00000000, "kseg0": 0x80000000, "kseg1": 0xA0000000} + + +def parse_segment(text): + key = text.strip().lower() + if key in SEGMENTS: + return SEGMENTS[key] + value = int(key, 0) + if (value & SEG_MASK) not in SEGMENTS.values(): + raise ValueError("not a RAM segment: %s" % text) + return value & SEG_MASK + + +def segment_from_game_toml(path): + if tomllib is None: + raise SystemExit("--game-toml needs Python 3.11+ (tomllib) or tomli; pass --segment") + with open(path, "rb") as f: + game = tomllib.load(f).get("game", {}) + if "load_address" in game: + return int(str(game["load_address"]), 0) & SEG_MASK, "%s [game].load_address" % path + exe = game.get("exe") or game.get("rom") + if not exe: + raise SystemExit("%s: [game] has neither load_address nor exe" % path) + exe_path = exe if os.path.isabs(exe) else os.path.join(os.path.dirname(os.path.abspath(path)), exe) + with open(exe_path, "rb") as f: + header = f.read(0x20) + if len(header) < 0x20 or header[:8] != b"PS-X EXE": + raise SystemExit("%s: not a PS-X EXE (from %s [game].exe)" % (exe_path, path)) + return struct.unpack_from(" Python %). + rows = ",".join(ROW_FMT.replace('\\"', '"').replace("%llu", "%d") % r + for r in SEGMENT_MISSES) + return '{"id":1,"ok":true,"total":5,"unique":2,"summary":[%s]}' % rows + return json.dumps({"id": 1, "ok": False, "error": "unknown command"}) + + def run(self): + while not self.stop: + try: + conn, _ = self.sock.accept() + except OSError: + return + with conn: + data = b"" + while not data.endswith(b"\n"): + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + cmd = json.loads(data.decode())["cmd"] + conn.sendall(self.reply(cmd).encode() + b"\n") + + def close(self): + self.stop = True + self.sock.close() + + +def check(cond, msg): + if not cond: + raise SystemExit("FAIL: " + msg) + + +def run_tool(port, out, *extra, lo="0x10000", hi="0x98000"): + return subprocess.run([sys.executable, TOOL, str(port), lo, hi, out] + list(extra), + capture_output=True, text=True) + + +def seeds(path): + with open(path) as f: + return [int(l.split()[0], 16) for l in f if l.strip() and not l.startswith("#")] + + +def main(): + with open(os.path.join(ROOT, "runtime", "src", "debug_server.c"), encoding="utf-8") as f: + check(ROW_FMT in f.read(), "the runtime's segment_misses row format changed; " + "update collect_game_misses.py and this fixture together") + + server = FakeDebugServer() + server.start() + try: + with tempfile.TemporaryDirectory() as tmp: + # KUSEG-linked title, segment given directly. + out = os.path.join(tmp, "kuseg.txt") + r = run_tool(server.port, out, "--segment", "kuseg") + check(r.returncode == 0, "tool failed: %s" % r.stderr) + got = seeds(out) + check(got[:2] == [0x000100E4, 0x800100F0] and + set(got) == {0x000100E4, 0x00010200, 0x800100F0, 0xA0010124}, + "KUSEG seeds, segment misses at their full PC, hits first: %s" + % ["0x%08X" % s for s in got]) + with open(out) as f: + text = f.read() + check("home 0x000100F0 (variant request)" in text, + "a segment miss is written as a variant request: %r" % text) + # 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 + len(seeds(out)) == 4, "a rerun must not duplicate seeds") + + # game.toml with load_address, and with only the EXE header. + toml_a = os.path.join(tmp, "a.toml") + with open(toml_a, "w") as f: + f.write("[game]\nexe = 'none.psx'\nload_address = '0x00010000'\n") + out = os.path.join(tmp, "toml_a.txt") + r = run_tool(server.port, out, "--game-toml", toml_a) + check(r.returncode == 0 and 0x000100E4 in seeds(out), + "--game-toml load_address gives KUSEG: %s %s" % (r.stdout, r.stderr)) + exe = os.path.join(tmp, "game.psx") + header = bytearray(0x800) + header[0:8] = b"PS-X EXE" + struct.pack_into("