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("