// psxrecomp — self-contained developer front-end. // // Disc image + BIOS in; generated C project + build scripts out. #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #else #include #include #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 --bios " "--output [--name ]\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; } }