mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
80ff96780a
The overlay capture format DELIBERATELY appends one coherent guard instruction
past the end of a dirty-page run (overlay_capture.c write_json_window, `size
+= 4u`, pinned by runtime/tests/test_interpreter_perf_guards.py:47): a MIPS
branch sitting at the run's final word (...FFC) always executes its delay slot
in the next page (...000), and the shard must be able to emit and hash that
word. So a capture whose size is a page multiple PLUS 4 is a CORRECT capture --
N bytes of overlay plus one guard word. Measured: 36 of 36 Ape Escape
(SCUS-94423) vault records and 846 of 946 Tomba 2 (SCUS-94454) records are that
shape.
The recompiler was never taught the asymmetry: the guard word is a legal
DELAY-SLOT SOURCE but an ILLEGAL BLOCK LEADER, because the word after it does
not exist in the image. It had exactly one bound -- end_address() -- used both
to read words and to decide what is code.
MEASURED on Ape Escape, its own single-source vault at framework
v0.3.2-alpha-139-g1bf70960, 36 variants:
PSX_SHARD_RESULT ok=50 failed=17 skipped=32 capacity_fastpath=0
! [recompiler] overlay 0x80136000 crc 4BAB23C9: what(): cannot emit control
flow at 0x80138000: mandatory delay slot 0x80138004 is outside the input
image
+ 16 x [fragment] interior 0x801360.. @region 0x00136000, identical message
One root cause, seventeen shards. The whole region then falls back to the
dirty-RAM interpreter forever, and nothing at runtime says so.
Causal chain, every step read in source:
1. overlay_capture.c:349-361 appends the guard word; region 0x80136000 gets
size 0x2004, so the image is [0x80136000, 0x80138004).
2. compile_overlays.py make_psxexe wraps those bytes verbatim with text size
= len(data), so PS1Executable::end_address() = 0x80138004.
3. function_analysis.cpp in_exe / in_function admit 0x80138000 as ordinary
code, and the walk reaches it by fall-through from 0x80137FFC
(0x24022000, addiu -- not control flow), so func.end_addr becomes the
image end.
4. control_flow.cpp makes the final block end_addr = func.end_addr - 4 =
0x80138000: the block LEADER is the guard word.
5. control_flow.cpp's "Also check last instruction itself" promotion finds
0x80137FFC is not control flow and therefore promotes read_word(end_addr)
-- the guard word 0x1062005A, a beq -- to block.exit_instr, with
has_delay_slot = true and address == end_addr. THIS IS THE DEFECT.
6. code_generator.cpp computes delay_slot_addr = 0x80138004, read_word
returns nullopt, and it throws.
THE THROW AT STEP 6 IS CORRECT AND IS PRESERVED. Emitting a control transfer
without its architectural delay slot silently corrupts guest state, which is
what the code did before that throw was added. The gap is step 5.
Fix: give the image view two distinct bounds.
end_address() READ bound -- every byte the image supplies, so a
mandatory delay slot living at a guard word resolves.
analysis_end_address() ANALYSIS bound -- one past the last byte that may be
discovered, made a block leader, or made a block's
exit instruction.
read_word() keeps the READ bound. Discovery and block construction move to the
ANALYSIS bound: function_analysis.cpp in_exe / in_exe_p3 / add_forced_entry /
every hard_cap that used to be the image end / the jr-table producer_hi
defaults / the static jr-ra and JAL sweeps; control_flow.cpp gains
analysis_walk_hi(func) and uses it for cfg.function_end, alias entries,
add_boundary, the scan loop, the final block's end_addr and in_func;
code_generator.cpp's mid-function split pre-pass; main_psx.cpp seed admission
and the data-scan pointer promotion; analysis_db.cpp, main_toml.cpp,
main_cli.cpp target/seed admission. The clamp is applied only when it leaves a
non-empty walk, so a degenerate function starting at or past the bound keeps
failing closed rather than silently producing an empty CFG.
HOW THE BOUND IS DERIVED -- the producer STATES it; the recompiler is told.
The alternative was for the recompiler to infer the count from `size % 4096 ==
4`. That fits every capture the current writer has ever produced, but it is a
consumer guessing at a producer's intent, and this tree already has the
counter-example: 52 of the 946 SCUS-94454 records have residues from 112 to
3996 bytes (one not even 4-byte aligned) because they come from the STATIC
extractor, which records an exact file extent and appends no guard word. A rule
keyed to the page-run shape is a rule that shifts the moment a producer
changes.
So the count travels with the image, in a magic-tagged field in the otherwise
zero-filled tail of the 2048-byte PS-EXE header (exe_tag in ps1_exe_parser.h:
"PSXRGRD1" at 0x7E0, uint32 count at 0x7E8). Putting it in the header rather
than on the command line means every consumer gets it automatically and no
future invocation path can forget to pass it -- compile_overlays has three
make_psxexe sites and five recompiler spawn sites. An absent or malformed tag
means ZERO guard bytes, which is exactly what every genuine PS-X EXE is: the
BIOS and every retail main executable have no guard word, their analysis bound
equals their read bound, and their analysis is bit-for-bit unchanged.
Producer side:
overlay_capture.c writes "guard_bytes": 4|0 per region -- the writer
stating its own intent, additive to the v2 schema and
preserved through coverage_vault.py compaction.
compile_overlays.py capture_guard_bytes(): prefer that field; for captures
recorded before it existed, reconstruct the single
shape write_json_window can produce (page multiple + 4)
and return 0 for everything else, which is the
fail-closed answer -- an unrecognised guard word is
analysed as code and the step-6 throw still fires
loudly at generation time.
make_psxexe guard_bytes is keyword-only with NO default, so a new
call site is a TypeError, not a silently mis-analysed
shard. The count is threaded through
compile_fragment_batch / compile_interior_fragment /
generate_interior_fragment_static and into the fragment
fail-memo key (bumped to v3, so a memo recorded under
the old analysis cannot suppress a retry under the
corrected one).
SECOND-ORDER DEFECT FIXED IN THE SAME PASS. generate_ranges_manifest extends a
range by +4 for any block whose exit instruction IS its last word, so the
cloned delay slot joins the candidate CRC and the page-generation watch. That
extension was unclamped. Reachable TODAY, independent of the guard word: a
branch-likely opcode (0x14-0x17, RESERVED on the R3000A) as the image's very
last word is short-circuited by translate_basic_block into an inline RI raise
BEFORE any delay slot is read, so generation succeeds and the manifest claims 4
bytes past the image end. Measured on origin/master with a 28-byte synthetic
image: `R 80010000 20` for an image ending at 0x8001001C. The cache would then
validate the shard against bytes the shard never saw. Now clamped to the READ
bound -- not the analysis bound, because a guard word genuinely IS compiled in
when it serves as a delay slot and must participate in the CRC.
DEAD CODE, documented rather than trusted: basic_block.cpp's BasicBlockAnalyzer
bounds its walk correctly but nothing in the tree constructs it. It is still
compiled and still hashed into the codegen tag, so a change there invalidates
caches without moving an emitted byte. Its bounds move to the analysis bound
too -- wiring it up must not reintroduce this defect -- and a header comment now
says where the live walker actually is.
TESTS. Both registered, so ctest runs them and the configure-time
registration guard is satisfied.
recompiler/tests/test_overlay_guard_word_analysis_bound.py drives
psxrecomp-game over five synthetic images:
A tagged, 2 pages + guard word, last real word not control flow, guard word
IS a beq -- the measured Ape shape. Must generate; the guard word must not
be emitted as an instruction; the block must publish 0x80012000 for the
runtime to dispatch; no range may claim it.
B byte-for-byte the same payload with NO tag. Must still fail closed with
the mandatory-delay-slot diagnostic naming 0x80012000. A and B differ only
in the header tag, which proves the tag is what changed behaviour and that
the throw was not weakened.
B2 a misaligned declaration (6). Must be rejected and behave like B.
C tagged, with a real branch at the last analysable word. The guard word
must be emitted AS the delay slot and must be covered by the manifest.
D branch-likely as the final word. Generation must succeed and no range may
exceed the image end.
Against origin/master's psxrecomp-game the same file reports exactly the two
things this commit changes, and passes B, B2 and C:
FAIL: tagged guard word still fails generation: what(): cannot emit control
flow at 0x80012000: mandatory delay slot 0x80012004 is outside the
input image
FAIL: range [0x80010000,0x80010020) claims 4 byte(s) past the image end
0x8001001C
tools/tests/test_overlay_guard_bytes_declaration.py pins the producer half:
field precedence, the reconstruction table, rejection of nonsense
declarations, the tag's byte offsets, that make_psxexe with guard_bytes=0 is
byte-identical to the pre-fix wrapper, and that overlay_capture.c both appends
the guard word and now declares it.
MEASURED OUTCOME, Ape Escape, same vault, same flags, fresh cache:
before PSX_SHARD_RESULT ok=50 failed=17 skipped=32 capacity_fastpath=0
after PSX_SHARD_RESULT ok=31 failed=0 skipped=57 capacity_fastpath=0
ok falls because 19 isolated island fragment shards at 0x00136000 are no longer
needed once the region variant compiles as one shard. Coverage is what matters,
and it is measured from the DLL exports: 349 -> 361 unique func_XXXXXXXX
entries, ZERO lost, 12 gained -- 0x80136140, 0x8013614C, 0x80136154,
0x80136238, 0x801362D4, 0x801362DC, 0x801362F0, 0x80136324, 0x80136370,
0x80136400, 0x80136480, 0x80137950, all from the failure list. The other four
failing PCs were already served by a sibling variant's shard.
INERTNESS.
BIOS (SCPH1001, psxrecomp-bios --config bios/SCPH1001.toml): all four
generated artifacts BYTE-IDENTICAL, verified by sha256 rather than by exit
code, because tools/regen_bios.sh exits 0 on failure.
Tomba 2 main EXE (psxrecomp-game --config game.toml): all nine generated
artifacts BYTE-IDENTICAL, and the console log is identical too.
Tomba 2 overlays, 82 variants over 6 regions from its own vault: identical
result line (ok=98 failed=0 skipped=103) and identical shard identity set,
4901 unique func exports on both sides -- zero gained, zero lost. 97 of 98
range manifests are byte-identical. The one that differs is region
0x8010A000 variant A32DB188, size 0x2004, whose last function's range goes
from 0x218 to 0x214 bytes: its guard word (0x24840008, addiu) had been
emitted as an ordinary instruction and hashed, and now is not. That is
precisely the intended change and nothing else moved.
Bead beads-eio.3.100.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
485 lines
21 KiB
C++
485 lines
21 KiB
C++
// psxrecomp — self-contained developer front-end.
|
|
//
|
|
// Disc image + BIOS in; generated C project + build scripts out.
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#include <process.h>
|
|
#else
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#include "fmt/format.h"
|
|
#include "bios_rom_alias.h"
|
|
#include "cli_boot_path.h"
|
|
#include "iso_reader.h"
|
|
#include "ps1_exe_parser.h"
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
struct Options {
|
|
fs::path disc;
|
|
fs::path bios;
|
|
fs::path output;
|
|
std::string name;
|
|
};
|
|
|
|
struct BiosProfile {
|
|
std::string filename;
|
|
std::string out_stem;
|
|
};
|
|
|
|
void usage(const char* program) {
|
|
fmt::print(
|
|
"Usage:\n"
|
|
" {} build --disc <game.cue|bin|iso|chd> --bios <PS1_BIOS.BIN> "
|
|
"--output <directory> [--name <title>]\n\n"
|
|
"The output contains generated game/BIOS C, game.toml, CMakeLists.txt,\n"
|
|
"and build scripts. No compiler toolchain is bundled.\n"
|
|
"Supported BIOS: OpenBIOS, SCPH1001, SCPH101, SCPH5552 (any 512 KiB PS1 BIOS dump).\n",
|
|
program);
|
|
}
|
|
|
|
std::string toml_string(std::string value) {
|
|
std::string out;
|
|
out.reserve(value.size());
|
|
for (char c : value) {
|
|
if (c == '\\') out += '/';
|
|
else if (c == '"') out += "\\\"";
|
|
else out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string trim(std::string value) {
|
|
while (!value.empty() && std::isspace((unsigned char)value.back())) value.pop_back();
|
|
size_t first = 0;
|
|
while (first < value.size() && std::isspace((unsigned char)value[first])) first++;
|
|
return value.substr(first);
|
|
}
|
|
|
|
std::string parse_boot_path(const std::string& cnf) {
|
|
std::string lower = cnf;
|
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
|
[](unsigned char c) { return (char)std::tolower(c); });
|
|
size_t pos = lower.find("cdrom:");
|
|
if (pos == std::string::npos) pos = lower.find("cdrom0:");
|
|
if (pos == std::string::npos) return {};
|
|
pos = lower.find(':', pos) + 1;
|
|
while (pos < cnf.size() && (cnf[pos] == '\\' || cnf[pos] == '/')) pos++;
|
|
std::string path;
|
|
while (pos < cnf.size()) {
|
|
char c = cnf[pos++];
|
|
if (c == ';' || c == '\r' || c == '\n' || c == '\0' ||
|
|
std::isspace((unsigned char)c)) break;
|
|
path += c;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
std::string safe_stem(std::string value) {
|
|
std::string out;
|
|
for (char c : value) {
|
|
if (std::isalnum((unsigned char)c)) out += c;
|
|
else if (!out.empty() && out.back() != '_') out += '_';
|
|
}
|
|
while (!out.empty() && out.back() == '_') out.pop_back();
|
|
return out.empty() ? "PSXGameRecomp" : out;
|
|
}
|
|
|
|
void write_file(const fs::path& path, const std::string& contents) {
|
|
fs::create_directories(path.parent_path());
|
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
|
if (!out) throw std::runtime_error("cannot write " + path.string());
|
|
out << contents;
|
|
if (!out) throw std::runtime_error("write failed for " + path.string());
|
|
}
|
|
|
|
void write_bytes(const fs::path& path, const std::vector<uint8_t>& contents) {
|
|
fs::create_directories(path.parent_path());
|
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
|
if (!out) throw std::runtime_error("cannot write " + path.string());
|
|
out.write((const char*)contents.data(), (std::streamsize)contents.size());
|
|
if (!out) throw std::runtime_error("write failed for " + path.string());
|
|
}
|
|
|
|
fs::path executable_dir(const char* argv0) {
|
|
std::error_code ec;
|
|
fs::path path = fs::absolute(argv0, ec);
|
|
if (ec) path = argv0;
|
|
path = fs::weakly_canonical(path, ec);
|
|
return (ec ? fs::path(argv0) : path).parent_path();
|
|
}
|
|
|
|
fs::path find_helper(const fs::path& exe_dir, const std::string& name) {
|
|
#ifdef _WIN32
|
|
const std::string filename = name + ".exe";
|
|
#else
|
|
const std::string filename = name;
|
|
#endif
|
|
for (const fs::path& candidate : {
|
|
exe_dir / "libexec" / filename,
|
|
exe_dir / filename,
|
|
}) {
|
|
if (fs::is_regular_file(candidate)) return fs::absolute(candidate);
|
|
}
|
|
throw std::runtime_error("missing packaged helper: " + filename);
|
|
}
|
|
|
|
fs::path find_framework(const fs::path& exe_dir) {
|
|
for (const fs::path& candidate : {
|
|
exe_dir / "framework",
|
|
exe_dir.parent_path().parent_path(),
|
|
}) {
|
|
if (fs::is_regular_file(candidate / "runtime" / "runtime.cmake"))
|
|
return fs::absolute(candidate);
|
|
}
|
|
throw std::runtime_error("packaged framework sources are missing");
|
|
}
|
|
|
|
const BiosProfile& select_bios_profile(const fs::path& bios_path) {
|
|
static const std::vector<std::pair<std::string, BiosProfile>> profiles = {
|
|
{"OPENBIOS", {"OpenBIOS.toml", "OpenBIOS"}},
|
|
{"SCPH1001", {"SCPH1001.toml", "SCPH1001"}},
|
|
{"SCPH101", {"SCPH101.toml", "SCPH101"}},
|
|
{"SCPH5552", {"SCPH5552.toml", "SCPH5552"}},
|
|
};
|
|
const std::string token = PSXRecompV4::bios_model_token(bios_path);
|
|
for (const auto& [candidate, profile] : profiles) {
|
|
if (candidate == token) return profile;
|
|
}
|
|
throw std::runtime_error(
|
|
"unsupported BIOS filename '" + bios_path.filename().string() +
|
|
"' (expected openbios.bin, SCPH1001.BIN, SCPH101.BIN, or SCPH5552.BIN)");
|
|
}
|
|
|
|
fs::path find_bios_profile(const fs::path& framework,
|
|
const BiosProfile& profile) {
|
|
for (const fs::path& candidate : {
|
|
framework / "bios" / profile.filename,
|
|
framework / profile.filename,
|
|
}) {
|
|
if (fs::is_regular_file(candidate)) return fs::absolute(candidate);
|
|
}
|
|
throw std::runtime_error(
|
|
"packaged BIOS profile is missing: " + profile.filename);
|
|
}
|
|
|
|
int run_process(const std::vector<std::string>& args) {
|
|
std::vector<char*> raw;
|
|
raw.reserve(args.size() + 1);
|
|
for (const std::string& arg : args) raw.push_back(const_cast<char*>(arg.c_str()));
|
|
raw.push_back(nullptr);
|
|
#ifdef _WIN32
|
|
intptr_t result = _spawnv(_P_WAIT, args[0].c_str(), raw.data());
|
|
return result < 0 ? -1 : (int)result;
|
|
#else
|
|
pid_t pid = fork();
|
|
if (pid < 0) return -1;
|
|
if (pid == 0) {
|
|
execv(args[0].c_str(), raw.data());
|
|
_exit(127);
|
|
}
|
|
int status = 0;
|
|
if (waitpid(pid, &status, 0) < 0) return -1;
|
|
return WIFEXITED(status) ? WEXITSTATUS(status) : 128;
|
|
#endif
|
|
}
|
|
|
|
class CurrentPathGuard {
|
|
public:
|
|
explicit CurrentPathGuard(const fs::path& path) : original_(fs::current_path()) {
|
|
fs::current_path(path);
|
|
}
|
|
~CurrentPathGuard() {
|
|
std::error_code ignored;
|
|
fs::current_path(original_, ignored);
|
|
}
|
|
|
|
CurrentPathGuard(const CurrentPathGuard&) = delete;
|
|
CurrentPathGuard& operator=(const CurrentPathGuard&) = delete;
|
|
|
|
private:
|
|
fs::path original_;
|
|
};
|
|
|
|
void copy_framework(const fs::path& source, const fs::path& destination) {
|
|
fs::create_directories(destination);
|
|
for (const auto& entry : fs::directory_iterator(source)) {
|
|
fs::copy(entry.path(), destination / entry.path().filename(),
|
|
fs::copy_options::recursive | fs::copy_options::overwrite_existing);
|
|
}
|
|
}
|
|
|
|
Options parse_options(int argc, char** argv) {
|
|
Options options;
|
|
for (int i = 2; i < argc; ++i) {
|
|
std::string arg = argv[i];
|
|
auto value = [&](const char* flag) -> std::string {
|
|
if (i + 1 >= argc) throw std::runtime_error(std::string(flag) + " needs a value");
|
|
return argv[++i];
|
|
};
|
|
if (arg == "--disc") options.disc = value("--disc");
|
|
else if (arg == "--bios") options.bios = value("--bios");
|
|
else if (arg == "--output" || arg == "-o") options.output = value("--output");
|
|
else if (arg == "--name") options.name = value("--name");
|
|
else if (arg == "-h" || arg == "--help") {
|
|
usage(argv[0]);
|
|
std::exit(0);
|
|
} else {
|
|
throw std::runtime_error("unknown argument: " + arg);
|
|
}
|
|
}
|
|
if (options.disc.empty() || options.bios.empty() || options.output.empty())
|
|
throw std::runtime_error("--disc, --bios, and --output are required");
|
|
options.disc = fs::absolute(options.disc);
|
|
options.bios = fs::absolute(options.bios);
|
|
options.output = fs::absolute(options.output);
|
|
return options;
|
|
}
|
|
|
|
int build_project(const Options& options, const fs::path& exe_dir) {
|
|
if (!fs::is_regular_file(options.disc))
|
|
throw std::runtime_error("disc image not found: " + options.disc.string());
|
|
if (!fs::is_regular_file(options.bios))
|
|
throw std::runtime_error("BIOS not found: " + options.bios.string());
|
|
if (fs::file_size(options.bios) != 512 * 1024)
|
|
throw std::runtime_error("BIOS must be an uncompressed 512 KiB PlayStation BIOS dump");
|
|
if (fs::exists(options.output)) {
|
|
if (!fs::is_directory(options.output))
|
|
throw std::runtime_error("output path is not a directory: " + options.output.string());
|
|
if (!fs::is_empty(options.output))
|
|
throw std::runtime_error("output directory is not empty: " + options.output.string());
|
|
}
|
|
|
|
PS1::ISOReader disc;
|
|
if (!disc.Open(options.disc.string()))
|
|
throw std::runtime_error("could not open the disc image or its CUE tracks");
|
|
size_t cnf_size = disc.GetFileSize("SYSTEM.CNF");
|
|
if (!cnf_size) throw std::runtime_error("SYSTEM.CNF was not found on the disc");
|
|
std::vector<uint8_t> cnf_bytes(cnf_size + 1, 0);
|
|
size_t cnf_read = disc.ReadFile("SYSTEM.CNF", cnf_bytes.data(), cnf_size);
|
|
std::string boot = parse_boot_path(std::string((char*)cnf_bytes.data(), cnf_read));
|
|
if (boot.empty()) throw std::runtime_error("SYSTEM.CNF does not contain a BOOT executable");
|
|
// Normalize boot path: convert backslashes to forward slashes so that
|
|
// std::filesystem::path (which treats '\' as a regular character on Linux)
|
|
// correctly splits the path into directory and filename components.
|
|
const std::string boot_norm = PSXRecompCLI::normalize_boot_path(boot);
|
|
size_t exe_size = disc.GetFileSize(boot_norm);
|
|
if (!exe_size) throw std::runtime_error("boot executable was not found on disc: " + boot);
|
|
std::vector<uint8_t> exe_bytes(exe_size);
|
|
if (disc.ReadFile(boot_norm, exe_bytes.data(), exe_bytes.size()) != exe_bytes.size())
|
|
throw std::runtime_error("failed to read the complete boot executable");
|
|
if (exe_bytes.size() < 2048 || std::string((char*)exe_bytes.data(), 8) != "PS-X EXE")
|
|
throw std::runtime_error("the disc BOOT file is not a PS-X EXE");
|
|
|
|
fs::create_directories(options.output);
|
|
// Mark the generated folder as its own project before config_loader walks
|
|
// upward looking for a project root. This matters when the output folder
|
|
// happens to live inside another checkout.
|
|
write_file(options.output / ".gitignore",
|
|
"build/\ninput/\ngenerated/\nbios-generated/\n*.mcr\nsaves/\n");
|
|
const std::string boot_file = PSXRecompCLI::boot_filename(boot_norm);
|
|
const fs::path local_exe = options.output / "input" / boot_file;
|
|
write_bytes(local_exe, exe_bytes);
|
|
|
|
std::string parse_error;
|
|
auto parsed = PSXRecomp::PS1ExeParser::parse_file(local_exe, parse_error);
|
|
if (!parsed) throw std::runtime_error("failed to parse boot executable: " + parse_error);
|
|
const auto& image = *parsed;
|
|
const std::string serial = PSXRecompCLI::serial_from_boot(boot_norm);
|
|
const std::string title = options.name.empty()
|
|
? trim(disc.GetVolumeID())
|
|
: options.name;
|
|
const std::string game_name = title.empty() ? serial : title;
|
|
const std::string project_name = safe_stem(game_name) + "Recomp";
|
|
const BiosProfile& bios_profile = select_bios_profile(options.bios);
|
|
const fs::path framework_source = find_framework(exe_dir);
|
|
const fs::path profile_source =
|
|
find_bios_profile(framework_source, bios_profile);
|
|
|
|
std::set<uint32_t> seeds = {image.entry_point()};
|
|
for (uint32_t address = image.load_address(); address + 4 <= image.analysis_end_address(); address += 4) {
|
|
auto word = image.read_word(address);
|
|
if (!word || ((*word >> 26) & 0x3F) != 0x03) continue;
|
|
uint32_t target = (address & 0xF0000000u) | ((*word & 0x03FFFFFFu) << 2);
|
|
if (target >= image.load_address() && target < image.analysis_end_address()) seeds.insert(target);
|
|
}
|
|
std::string seed_text = fmt::format("# Auto-generated JAL targets for {}\n", serial);
|
|
for (uint32_t seed : seeds) seed_text += fmt::format("0x{:08X}\n", seed);
|
|
write_file(options.output / "seeds" / "functions.txt", seed_text);
|
|
|
|
fmt::print("[1/4] Copying build framework...\n");
|
|
const fs::path framework_destination = options.output / "psxrecomp";
|
|
copy_framework(framework_source, framework_destination);
|
|
const fs::path profile_destination =
|
|
framework_destination / "bios" / bios_profile.filename;
|
|
fs::create_directories(profile_destination.parent_path());
|
|
fs::copy_file(profile_source, profile_destination,
|
|
fs::copy_options::overwrite_existing);
|
|
|
|
uint32_t text_size = image.code_size();
|
|
if (text_size & 0xFFF) text_size = (text_size + 0xFFF) & ~0xFFFu;
|
|
const uint32_t stack = image.header.stack_base ? image.header.stack_base : 0x801FFFF0u;
|
|
const std::string config = fmt::format(
|
|
"[game]\n"
|
|
"name = \"{}\"\n"
|
|
"id = \"{}\"\n"
|
|
"exe = \"input/{}\"\n"
|
|
"disc = \"{}\"\n"
|
|
"load_address = \"0x{:08X}\"\n"
|
|
"entry_pc = \"0x{:08X}\"\n"
|
|
"text_size = \"0x{:X}\"\n"
|
|
"stack_base = \"0x{:08X}\"\n\n"
|
|
"[recompiler]\n"
|
|
"seeds = \"seeds/functions.txt\"\n"
|
|
"bios_config = \"psxrecomp/bios/{}\"\n"
|
|
"strict = true\n"
|
|
"out_dir = \"generated\"\n\n"
|
|
"[runtime]\n"
|
|
"window_title = \"{} Recompiled\"\n"
|
|
"bios_hle = false\n"
|
|
"overlay_cache = true\n\n"
|
|
"[video]\n"
|
|
"renderer = \"opengl\"\n",
|
|
toml_string(game_name), serial, toml_string(boot_file),
|
|
toml_string(options.disc.generic_string()), image.load_address(),
|
|
image.entry_point(), text_size, stack, bios_profile.filename,
|
|
toml_string(game_name));
|
|
write_file(options.output / "game.toml", config);
|
|
|
|
const fs::path game_tool = find_helper(exe_dir, "psxrecomp-game");
|
|
const fs::path bios_tool = find_helper(exe_dir, "psxrecomp-bios");
|
|
fmt::print("[2/4] Recompiling game executable...\n");
|
|
{
|
|
CurrentPathGuard project_directory(options.output);
|
|
if (run_process({game_tool.string(), "--config", "game.toml"}))
|
|
throw std::runtime_error("game recompilation failed");
|
|
}
|
|
fmt::print("[3/4] Recompiling BIOS...\n");
|
|
const fs::path bios_generated = framework_destination / "generated";
|
|
fs::create_directories(bios_generated);
|
|
if (run_process({bios_tool.string(),
|
|
"--config", fs::absolute(profile_destination).string(),
|
|
"--rom", options.bios.string(),
|
|
"--out-dir",
|
|
fs::absolute(bios_generated).string()}))
|
|
throw std::runtime_error("BIOS recompilation failed");
|
|
|
|
const std::string cmake = fmt::format(
|
|
"cmake_minimum_required(VERSION 3.20)\n"
|
|
"set(PSXRECOMP_ROOT \"${{CMAKE_CURRENT_SOURCE_DIR}}/psxrecomp\")\n"
|
|
"if(NOT EXISTS \"${{PSXRECOMP_ROOT}}/runtime/runtime.cmake\")\n"
|
|
" message(FATAL_ERROR\n"
|
|
" \"PSXRecomp runtime is missing: ${{PSXRECOMP_ROOT}}/runtime/runtime.cmake\\n\"\n"
|
|
" \"This generated project needs the psxrecomp framework tree at ${{PSXRECOMP_ROOT}}.\\n\"\n"
|
|
" \"If this project came from git, run: git submodule update --init --recursive\\n\"\n"
|
|
" \"If this project came from psxrecomp.exe, regenerate it from the full CLI zip and keep the generated psxrecomp/ folder.\")\n"
|
|
"endif()\n"
|
|
"project({} C CXX)\n"
|
|
"set(CMAKE_C_STANDARD 99)\n"
|
|
"set(CMAKE_CXX_STANDARD 17)\n"
|
|
"set(PSX_RECOMP_UI OFF CACHE BOOL \"\" FORCE)\n"
|
|
"set(PSXRECOMP_BIOS_STEMS \"{}\" CACHE STRING \"\" FORCE)\n"
|
|
"include(\"${{PSXRECOMP_ROOT}}/runtime/runtime.cmake\")\n"
|
|
"file(GLOB GAME_FULL CONFIGURE_DEPENDS\n"
|
|
" \"${{CMAKE_CURRENT_SOURCE_DIR}}/generated/*_full.c\"\n"
|
|
" \"${{CMAKE_CURRENT_SOURCE_DIR}}/generated/*_full_*.c\")\n"
|
|
"file(GLOB GAME_DISPATCH \"${{CMAKE_CURRENT_SOURCE_DIR}}/generated/*_dispatch.c\")\n"
|
|
"psxrecomp_add_runtime_target(psx-runtime\n"
|
|
" GAME_GENERATED_FULL_C \"${{GAME_FULL}}\"\n"
|
|
" GAME_GENERATED_DISPATCH_C \"${{GAME_DISPATCH}}\"\n"
|
|
" WINDOW_TITLE \"{} Recompiled\"\n"
|
|
" DEFAULT_GAME_CONFIG_PATH \"game.toml\"\n"
|
|
")\n",
|
|
project_name, bios_profile.out_stem, game_name);
|
|
write_file(options.output / "CMakeLists.txt", cmake);
|
|
|
|
write_file(options.output / "build.ps1",
|
|
"$ErrorActionPreference = 'Stop'\n"
|
|
"$Root = Split-Path -Parent $MyInvocation.MyCommand.Path\n"
|
|
"$RuntimeCMake = Join-Path $Root 'psxrecomp/runtime/runtime.cmake'\n"
|
|
"if (-not (Test-Path -LiteralPath $RuntimeCMake)) {\n"
|
|
" Write-Error \"PSXRecomp runtime is missing: $RuntimeCMake`nThis generated project needs the psxrecomp framework tree at '$Root\\psxrecomp'.`nIf this project came from git, run: git submodule update --init --recursive`nIf this project came from psxrecomp.exe, regenerate it from the full CLI zip and keep the generated psxrecomp folder.\"\n"
|
|
" exit 1\n"
|
|
"}\n"
|
|
"cmake -S $Root -B (Join-Path $Root 'build') -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF\n"
|
|
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n"
|
|
"cmake --build (Join-Path $Root 'build') --config Release --parallel\n"
|
|
"exit $LASTEXITCODE\n");
|
|
write_file(options.output / "build.sh",
|
|
"#!/usr/bin/env sh\n"
|
|
"set -eu\n"
|
|
"ROOT=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\n"
|
|
"if [ ! -f \"$ROOT/psxrecomp/runtime/runtime.cmake\" ]; then\n"
|
|
" echo \"error: PSXRecomp runtime is missing: $ROOT/psxrecomp/runtime/runtime.cmake\" >&2\n"
|
|
" echo \"This generated project needs the psxrecomp framework tree at $ROOT/psxrecomp.\" >&2\n"
|
|
" echo \"If this project came from git, run: git submodule update --init --recursive\" >&2\n"
|
|
" echo \"If this project came from psxrecomp.exe, regenerate it from the full CLI zip and keep the generated psxrecomp folder.\" >&2\n"
|
|
" exit 1\n"
|
|
"fi\n"
|
|
"cmake -S \"$ROOT\" -B \"$ROOT/build\" -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_RECOMP_UI=OFF\n"
|
|
"cmake --build \"$ROOT/build\" --config Release --parallel\n");
|
|
write_file(options.output / ".gitignore",
|
|
"build/\ninput/\ngenerated/\nbios-generated/\n*.mcr\nsaves/\n");
|
|
write_file(options.output / "README.md", fmt::format(
|
|
"# {}\n\n"
|
|
"Generated locally by PSXRecomp from your own disc and BIOS.\n\n"
|
|
"## Build\n\n"
|
|
"Install CMake, Ninja, and a C/C++ compiler. SDL3 is fetched automatically.\n"
|
|
"Then run `sh build.sh` on macOS/Linux or `.\\build.ps1` in PowerShell.\n\n"
|
|
"This project must keep the generated `psxrecomp/` framework folder beside\n"
|
|
"`CMakeLists.txt`. If it is missing, restore it from source control with\n"
|
|
"`git submodule update --init --recursive` or regenerate the project from\n"
|
|
"the full PSXRecomp CLI zip.\n\n"
|
|
"SDL3 is the default. To use SDL2 explicitly, configure once with\n"
|
|
"`cmake -S . -B build -DPSX_SDL_BACKEND=SDL2`; later build-script runs\n"
|
|
"preserve that cached selection.\n\n"
|
|
"The executable is written under `build/`. Keep your original disc image\n"
|
|
"at the path stored in `game.toml`, or update that path before running.\n\n"
|
|
"The `input/`, `generated/`, and `bios-generated/` folders contain data\n"
|
|
"derived from copyrighted files you supplied. Do not redistribute them.\n",
|
|
game_name));
|
|
fmt::print("[4/4] Wrote project files.\n\nReady: {}\nBuild with: {}\n",
|
|
options.output.string(),
|
|
#ifdef _WIN32
|
|
(options.output / "build.ps1").string()
|
|
#else
|
|
(options.output / "build.sh").string()
|
|
#endif
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
try {
|
|
if (argc < 2 || std::string(argv[1]) == "-h" || std::string(argv[1]) == "--help") {
|
|
usage(argv[0]);
|
|
return argc < 2 ? 2 : 0;
|
|
}
|
|
if (std::string(argv[1]) != "build") {
|
|
usage(argv[0]);
|
|
throw std::runtime_error("unknown command: " + std::string(argv[1]));
|
|
}
|
|
return build_project(parse_options(argc, argv), executable_dir(argv[0]));
|
|
} catch (const std::exception& error) {
|
|
fmt::print(stderr, "psxrecomp: error: {}\n", error.what());
|
|
return 1;
|
|
}
|
|
}
|