Files
psxrecomp/recompiler/src/main_psx.cpp
T
Shokunin dd5fdec6bb Add opt-in draw-distance clamps ([[draw_distance.clamp]])
Many PS1 renderers drop a primitive whose depth index falls past the end
of their ordering table. Raising that limit can write past the table, so
a title can now list such guards in game.toml with the register that
carries the index and the last safe value. While a trusted mod has called
psx_mod_set_draw_distance_clamp(1), generated main-EXE code clamps that
register before the guard runs, so far geometry is kept in the farthest
slot instead of popping in later.

- Off by default and reset at every session start, so netplay and every
  title that lists no sites run unchanged; with no sites the generated
  code and declarations are byte-identical.
- Main executable only: the full instruction word is the guard,
  generation fails on a mismatch or a load-delay hazard, captured
  overlay code keeps its own code (the sites are not part of the overlay
  config identity), and the dirty-RAM interpreter applies the clamp
  where the game's text image holds the listed word.
- Compatibility: code_generator.cpp is a codegen-hash input, so the
  codegen hash and the overlay-cache tag change for every title, sites
  or not. Savestates from earlier builds are refused, and overlay shards
  rebuild once on first visit. Generated code and behaviour are
  unchanged for a title without sites or with the switch off.
- Tests: draw_distance_codegen_test (config rules, emission, guards,
  declarations, overlay identity) and draw_distance_interp_test (store,
  switch, interpreted guards); the session-reset guard covers the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:36:48 -07:00

1804 lines
93 KiB
C++

#include <cstdio>
#include <exception>
#include <cerrno>
#include <filesystem>
#include <fstream>
#include <initializer_list>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <algorithm>
#include <set>
#include <vector>
#include "ps1_exe_parser.h"
#include "gte.h"
#include "function_analysis.h"
#include "control_flow.h"
#include "code_generator.h"
#include "game_dispatch_emitter.h"
#include "segment_variants.h"
#include "annotations.hpp"
#include "config_loader.h"
#include "rabbitizer.hpp"
#include "fmt/format.h"
#include "write_if_changed.h"
/* Baked emitter-source hash (recompiler/CMakeLists.txt custom command; same
* hash_codegen.cmake + canonical source list as the runtime's overlay cache
* tag). Exposed via --codegen-hash so compile_overlays.py can verify THIS
* binary was built from the same emitter sources as the cg tag it stamps —
* the stale-recompiler-binary guard. 0 when built without the bake (guard
* consumers treat that as "cannot verify" and fail loudly). */
#if defined(__has_include)
# if __has_include("psxrecomp_baked_codegen_hash.h")
# include "psxrecomp_baked_codegen_hash.h"
# endif
#endif
#ifndef PSX_OVERLAY_CODEGEN_HASH
# define PSX_OVERLAY_CODEGEN_HASH 0u
#endif
namespace {
struct AliasEntry { uint32_t addr, host_start, host_end; };
// Materialize alias entries as overlapping functions, grouped by host. Each
// group shares one emitted body (entry switch + host-range blocks); every
// member lists its siblings so all group CFGs carry identical block leaders.
// Host functions are emitted unchanged — aliases never cap or truncate them.
void materialize_alias_groups(PSXRecomp::FunctionAnalysisResult& result,
const std::vector<AliasEntry>& alias_entries) {
std::map<uint32_t, std::vector<const AliasEntry*>> by_host;
for (const auto& ae : alias_entries) {
by_host[ae.host_start].push_back(&ae);
}
for (const auto& [host_start, group] : by_host) {
uint32_t producer_lo = 0;
uint32_t producer_hi = 0;
for (const auto& function : result.functions) {
if (function.start_addr == host_start) {
producer_lo = function.producer_lo;
producer_hi = function.producer_hi;
break;
}
}
std::vector<uint32_t> entries;
for (const AliasEntry* ae : group) entries.push_back(ae->addr);
for (const AliasEntry* ae : group) {
PSXRecomp::Function af;
af.start_addr = ae->addr;
af.end_addr = ae->host_end;
af.size = af.end_addr - af.start_addr;
af.has_prologue = false;
af.has_epilogue = false;
af.stack_frame_size = 0;
af.name = fmt::format("func_{:08X}", ae->addr);
af.is_data_section = false;
af.alias_walk_lo = ae->host_start;
af.alias_group_entries = entries;
af.producer_lo = producer_lo;
af.producer_hi = producer_hi;
result.functions.push_back(af);
}
}
}
} // namespace
static int psxrecomp_game_main(int argc, char** argv);
// Configuration and generation errors are reported by throwing: a malformed
// game.toml (a bad hex address, an out-of-range value, a wrong TOML type),
// or an input the emitter must refuse (an overlay whose mandatory delay slot
// lies outside the image). Left uncaught, the exception terminates through
// abort(): the process dies by SIGABRT and macOS files a crash report instead
// of the caller seeing a diagnostic. Every caller already treats any non-zero
// status as failure, so print the message and exit with status 1.
int main(int argc, char** argv) {
try {
return psxrecomp_game_main(argc, argv);
} catch (const std::exception& e) {
std::fflush(stdout);
fmt::print(stderr, "ERROR: {}\n", e.what());
return 1;
}
}
static int psxrecomp_game_main(int argc, char** argv) {
const auto print_usage = [&]() {
fmt::print("Usage: {} --config <game.toml>\n", argv[0]);
fmt::print(" {} <PS1-EXE file> [--seeds <file>] [--out-dir <dir>] [--strict] [--inspect]\n", argv[0]);
fmt::print("Example: {} SCUS_942.36 --seeds seeds/functions.txt --out-dir generated --strict\n", argv[0]);
fmt::print("\n");
fmt::print(" --project-root <dir> Resolve the BIOS profile (bios/SCPH1001.toml, or\n");
fmt::print(" <framework>/bios/SCPH1001.toml one level down) against\n");
fmt::print(" <dir> instead of the CWD. Required whenever the caller\n");
fmt::print(" cannot choose its own CWD — e.g. compile_overlays.py,\n");
fmt::print(" which runs us with cwd = dirname(game.toml).\n");
};
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "-h" || arg == "--help") {
print_usage();
return 0;
}
}
if (argc < 2) {
print_usage();
return 0;
}
/* --codegen-hash: print the baked emitter-source hash and exit. Handled
* before the banner so the output is machine-parseable (one hex line).
* compile_overlays.py compares this to the hash from --runtime-include and
* refuses to build shards with a stale recompiler binary. */
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--codegen-hash") {
fmt::print("{:08x}\n", (unsigned)PSX_OVERLAY_CODEGEN_HASH);
return 0;
}
if (std::string(argv[i]) == "--overlay-config-hash" && i + 1 < argc) {
try {
const auto cfg = PSXRecompV4::load_game_config(argv[i + 1]);
fmt::print("{:08x}\n",
(unsigned)PSXRecompV4::overlay_codegen_config_hash(cfg));
return 0;
} catch (const std::exception& e) {
fmt::print(stderr, "overlay config hash failed: {}\n", e.what());
return 2;
}
}
}
fmt::print("PSXRecomp - PlayStation 1 Static Recompiler\n");
fmt::print("============================================\n\n");
// ── --config <path> short-form ────────────────────────────────────
// If --config is provided, all paths come from the TOML and other
// CLI flags are ignored. Positional form below stays for back-compat.
std::filesystem::path config_path;
// --ws-config <path>: load ONLY the [widescreen] site lists from this TOML
// without treating it as the full game config. Used by compile_overlays.py
// so overlay compilation gets the widescreen emits (sprite-tag / cull /
// backdrop) whose addresses live in overlay code — the overlay path is
// positional (no --config), so this is the channel for those sites.
std::filesystem::path ws_config_path;
// --project-root <dir>: the directory to resolve the BIOS profile against
// when no explicit [recompiler] bios_config is in play. Without it the
// probe below uses the process CWD, which is wrong for every caller that
// cannot choose its own CWD — notably compile_overlays.py, which spawns us
// with cwd = dirname(game.toml). For a PACKAGED config (packaging/release/
// game.toml) that directory holds neither bios/SCPH1001.toml nor a vendored
// framework, so every shard died with "no BIOS profile found" and the whole
// overlay cache silently failed to build (issue #72).
std::filesystem::path project_root;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--config" && i + 1 < argc) {
config_path = argv[i + 1];
break;
}
if (a.rfind("--config=", 0) == 0) {
config_path = a.substr(std::string("--config=").size());
break;
}
}
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--ws-config" && i + 1 < argc) { ws_config_path = argv[i + 1]; break; }
if (a.rfind("--ws-config=", 0) == 0) {
ws_config_path = a.substr(std::string("--ws-config=").size());
break;
}
}
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--project-root" && i + 1 < argc) { project_root = argv[i + 1]; break; }
if (a.rfind("--project-root=", 0) == 0) {
project_root = a.substr(std::string("--project-root=").size());
break;
}
}
if (!project_root.empty()) {
std::error_code prec;
if (!std::filesystem::is_directory(project_root, prec)) {
fmt::print(stderr,
"psxrecomp-game: FATAL: --project-root '{}' is not a directory\n",
project_root.string());
return 1;
}
}
std::filesystem::path exe_path;
std::string extra_funcs_storage; // lifetime anchor for the .c_str() below
const char* extra_funcs_path = nullptr;
bool inspect_mode = false;
bool overlay_mode = false;
bool reachable_discovery = false;
std::set<uint32_t> ws_tag_funcs; // [widescreen] sprite_tag_funcs
std::set<uint32_t> ds_funcs; // [data_shards] funcs
std::set<uint32_t> mod_entry_funcs; // trusted game-mod entry hooks
std::set<uint32_t> hot_funcs; // [recompiler] hot_funcs
std::set<uint32_t> load_charge_batch_funcs; // [recompiler] load_charge_batch*
std::map<uint32_t, std::array<uint32_t, 4>> vsync_query_hle_funcs;
std::set<uint32_t> ws_cull_bias, ws_cull_range, ws_cull_a1; // [widescreen.cull]
std::set<uint32_t> ws_cull_bias_lower;
std::set<uint32_t> ws_cull_screen_x; // [widescreen.cull] screen_x_sites
std::set<uint32_t> ws_cull_slti; // [widescreen.cull] slti_sites
std::set<uint32_t> ws_cull_slti_lower; // [widescreen.cull] slti_lower_sites
std::set<uint32_t> ws_cull_bltz; // [widescreen.cull] bltz_sites
std::set<uint32_t> ws_cull_negsub; // [widescreen.cull] negsub_sites
std::set<uint32_t> ws_cull_vxrange; // [widescreen.cull] vxrange_sites
std::set<uint32_t> ws_cull_depth; // [widescreen.cull] depth_sites
std::set<uint32_t> ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites
std::set<uint32_t> ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites
std::set<uint32_t> ws_cull_nclip_keep; // [widescreen.cull] nclip_keep_sites
std::set<uint32_t> ws_cull_nclip_exact; // [widescreen.cull] nclip_exact_sites
std::set<uint32_t> ws_cull_branch_keep; // [widescreen.cull] branch_keep_sites
std::set<uint32_t> ws_cull_bgez; // [widescreen.cull] bgez_sites
std::set<uint32_t> ws_cull_clip_edge_x_load; // [widescreen.cull] clip_edge_x_load_sites
uint32_t ws_cull_clip_edge_width = 0; // [widescreen.cull] clip_edge_width
std::vector<PSXRecompV4::WidescreenCullKeepSite> ws_cull_keep;
std::vector<PSXRecompV4::WidescreenAngleSite> ws_cull_angle;
std::vector<PSXRecompV4::DrawDistanceClampSite> draw_distance_clamps; // [[draw_distance.clamp]]
PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone;
int ws_cull_activation_guard_pixels = 0;
std::vector<uint32_t> ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms
std::vector<uint32_t> ws_cull_h_imms = { 0xE0, 0xF1 }; // [widescreen.cull] screen_h_imms
std::set<uint32_t> ws_backdrop_x; // [widescreen.backdrop] x_sites
std::set<uint32_t> ws_backdrop_unsquash; // [widescreen.backdrop] unsquash_funcs
bool ws_auto_screen_x_cull = false; // [widescreen.cull] auto_screen_x
std::set<uint32_t> 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<uint32_t> vsync_horizon_sites, vsync_horizon_extra_sites;
std::vector<PSXRecompV4::RecompilerPatch> instruction_patches;
std::vector<PSXRecompV4::WidescreenSignedBoundSite> ws_signed_x_bound_sites;
bool ws_auto_backdrop_preload = false; // [widescreen.cull] auto_backdrop
uint32_t ws_bg2d_count_site = 0, ws_bg2d_startcol_site = 0,
ws_bg2d_startx_site = 0,
ws_bg2d_stream_left_site = 0,
ws_bg2d_stream_right_site = 0,
ws_bg2d_bufbase_site = 0,
ws_bg2d_cap_site = 0,
ws_bg2d_init_func = 0; // [widescreen.bg2d]
std::filesystem::path out_dir = "generated";
uint32_t configured_text_size = 0;
std::filesystem::path bios_profile_path; // [recompiler] bios_config
if (!config_path.empty()) {
const auto cfg = PSXRecompV4::load_game_config(config_path);
exe_path = cfg.exe_path;
configured_text_size = cfg.text_size;
reachable_discovery = cfg.discovery == "reachable";
extra_funcs_storage = cfg.seeds_path.string();
extra_funcs_path = extra_funcs_storage.c_str();
bios_profile_path = cfg.bios_config_path;
out_dir = cfg.out_dir;
instruction_patches = cfg.recompiler_patches;
ws_tag_funcs.insert(cfg.ws_sprite_tag_funcs.begin(),
cfg.ws_sprite_tag_funcs.end());
ds_funcs.insert(cfg.data_shard_funcs.begin(), cfg.data_shard_funcs.end());
mod_entry_funcs.insert(cfg.mod_function_entry_funcs.begin(),
cfg.mod_function_entry_funcs.end());
hot_funcs.insert(cfg.hot_funcs.begin(), cfg.hot_funcs.end());
if (cfg.load_charge_batch) {
load_charge_batch_funcs.insert(cfg.load_charge_batch_funcs.begin(),
cfg.load_charge_batch_funcs.end());
}
if (cfg.vsync_query_func)
vsync_query_hle_funcs[cfg.vsync_query_func] = {
cfg.vsync_counter_addr, cfg.vsync_gpustat_ptr_addr,
cfg.vsync_timer1_ptr_addr, cfg.vsync_timer1_cache_addr };
ws_cull_bias.insert(cfg.ws_cull_bias_sites.begin(), cfg.ws_cull_bias_sites.end());
ws_cull_bias_lower.insert(cfg.ws_cull_bias_lower_sites.begin(), cfg.ws_cull_bias_lower_sites.end());
ws_cull_range.insert(cfg.ws_cull_range_sites.begin(), cfg.ws_cull_range_sites.end());
ws_cull_a1.insert(cfg.ws_cull_a1_sites.begin(), cfg.ws_cull_a1_sites.end());
ws_cull_screen_x.insert(cfg.ws_cull_screen_x_sites.begin(), cfg.ws_cull_screen_x_sites.end());
ws_cull_slti.insert(cfg.ws_cull_slti_sites.begin(), cfg.ws_cull_slti_sites.end());
ws_cull_slti_lower.insert(cfg.ws_cull_slti_lower_sites.begin(),
cfg.ws_cull_slti_lower_sites.end());
ws_cull_bltz.insert(cfg.ws_cull_bltz_sites.begin(), cfg.ws_cull_bltz_sites.end());
ws_cull_negsub.insert(cfg.ws_cull_negsub_sites.begin(), cfg.ws_cull_negsub_sites.end());
ws_cull_vxrange.insert(cfg.ws_cull_vxrange_sites.begin(), cfg.ws_cull_vxrange_sites.end());
ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end());
ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end());
ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end());
ws_cull_nclip_keep.insert(cfg.ws_cull_nclip_keep_sites.begin(), cfg.ws_cull_nclip_keep_sites.end());
ws_cull_nclip_exact.insert(cfg.ws_cull_nclip_exact_sites.begin(), cfg.ws_cull_nclip_exact_sites.end());
ws_cull_branch_keep.insert(cfg.ws_cull_branch_keep_sites.begin(), cfg.ws_cull_branch_keep_sites.end());
ws_cull_bgez.insert(cfg.ws_cull_bgez_sites.begin(), cfg.ws_cull_bgez_sites.end());
ws_cull_clip_edge_x_load.insert(cfg.ws_cull_clip_edge_x_load_sites.begin(),
cfg.ws_cull_clip_edge_x_load_sites.end());
if (!cfg.ws_cull_clip_edge_x_load_sites.empty())
ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(cfg);
ws_cull_keep = cfg.ws_cull_keep_sites;
ws_cull_angle = cfg.ws_cull_angle_sites;
draw_distance_clamps = cfg.draw_distance_clamp_sites;
ws_aspect_cone = cfg.ws_aspect_cone;
ws_cull_activation_guard_pixels =
cfg.ws_cull_activation_guard_pixels;
ws_cull_w_imms = cfg.ws_cull_w_imms;
ws_cull_h_imms = cfg.ws_cull_h_imms;
ws_backdrop_x.insert(cfg.ws_backdrop_x_sites.begin(), cfg.ws_backdrop_x_sites.end());
ws_backdrop_unsquash.insert(cfg.ws_backdrop_unsquash_funcs.begin(), cfg.ws_backdrop_unsquash_funcs.end());
ws_auto_screen_x_cull = ws_auto_screen_x_cull || cfg.ws_auto_screen_x_cull;
ws_auto_backdrop_preload = ws_auto_backdrop_preload || cfg.ws_auto_backdrop_preload;
if (cfg.ws_bg2d_count_site) ws_bg2d_count_site = cfg.ws_bg2d_count_site;
if (cfg.ws_bg2d_startcol_site) ws_bg2d_startcol_site = cfg.ws_bg2d_startcol_site;
if (cfg.ws_bg2d_startx_site) ws_bg2d_startx_site = cfg.ws_bg2d_startx_site;
if (cfg.ws_bg2d_stream_left_site) ws_bg2d_stream_left_site = cfg.ws_bg2d_stream_left_site;
if (cfg.ws_bg2d_stream_right_site) ws_bg2d_stream_right_site = cfg.ws_bg2d_stream_right_site;
if (cfg.ws_bg2d_bufbase_site) ws_bg2d_bufbase_site = cfg.ws_bg2d_bufbase_site;
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:
// a missing/bad file just means no persistence hooks this build.
try {
std::filesystem::path go_path =
std::filesystem::path(config_path).parent_path() / "game_options.toml";
const auto goc = PSXRecompV4::load_game_options(go_path);
for (const auto& o : goc.options)
if (o.init_store_pc) persist_init_sites.insert(o.init_store_pc);
} catch (const std::exception& e) {
fmt::print(stderr, " game_options.toml ignored: {}\n", e.what());
}
fmt::print("config: {}\n", config_path.string());
fmt::print(" exe = {}\n", exe_path.string());
fmt::print(" seeds = {}\n", extra_funcs_storage);
fmt::print(" out_dir = {}\n", out_dir.string());
if (!ws_tag_funcs.empty())
fmt::print(" ws_tag_funcs= {}\n", ws_tag_funcs.size());
fmt::print("\n");
} else {
exe_path = argv[1];
for (int i = 2; i < argc; i++) {
std::string arg = argv[i];
if ((arg == "--extra-funcs" || arg == "--seeds") && i + 1 < argc) {
extra_funcs_path = argv[++i];
} else if (arg == "--out-dir" && i + 1 < argc) {
out_dir = argv[++i];
} else if (arg == "--strict") {
/* The PS-X EXE path is fail-loud by default; accepted for parity
* with psxrecomp-bios and project scripts. */
} else if (arg == "--inspect") {
inspect_mode = true;
} else if (arg == "--project-root" && i + 1 < argc) {
/* Already parsed in the pre-scan above (the BIOS-profile probe
* needs it before this loop runs). Consume the value here so it
* is never mistaken for another flag's argument. */
++i;
} else if (arg == "--overlay") {
/* Overlay-compilation contract: this input is a runtime-captured
* overlay with execution evidence, so discovery is evidence-scoped
* (no whole-byte sweep) and branch/jump-table targets stay as
* in-parent labels. Set unconditionally by compile_overlays.py for
* every overlay input — never a human toggle. */
overlay_mode = true;
}
}
}
// --ws-config: merge the [widescreen] site lists from a side config WITHOUT
// adopting its exe/paths. The overlay path is positional (no --config), so
// this is how overlay compilation receives the widescreen emits whose
// addresses live in overlay code (backdrop screenX, and any sprite-tag /
// cull sites that resolve there). No-op when --config already provided the
// sites (same TOML) — std::set insert dedupes.
if (!ws_config_path.empty()) {
const auto wscfg = PSXRecompV4::load_game_config(ws_config_path);
ws_tag_funcs.insert(wscfg.ws_sprite_tag_funcs.begin(), wscfg.ws_sprite_tag_funcs.end());
mod_entry_funcs.insert(wscfg.mod_function_entry_funcs.begin(),
wscfg.mod_function_entry_funcs.end());
ws_cull_bias.insert(wscfg.ws_cull_bias_sites.begin(), wscfg.ws_cull_bias_sites.end());
ws_cull_bias_lower.insert(wscfg.ws_cull_bias_lower_sites.begin(), wscfg.ws_cull_bias_lower_sites.end());
ws_cull_range.insert(wscfg.ws_cull_range_sites.begin(), wscfg.ws_cull_range_sites.end());
ws_cull_a1.insert(wscfg.ws_cull_a1_sites.begin(), wscfg.ws_cull_a1_sites.end());
ws_cull_screen_x.insert(wscfg.ws_cull_screen_x_sites.begin(), wscfg.ws_cull_screen_x_sites.end());
ws_cull_slti.insert(wscfg.ws_cull_slti_sites.begin(), wscfg.ws_cull_slti_sites.end());
ws_cull_slti_lower.insert(wscfg.ws_cull_slti_lower_sites.begin(),
wscfg.ws_cull_slti_lower_sites.end());
ws_cull_bltz.insert(wscfg.ws_cull_bltz_sites.begin(), wscfg.ws_cull_bltz_sites.end());
ws_cull_negsub.insert(wscfg.ws_cull_negsub_sites.begin(), wscfg.ws_cull_negsub_sites.end());
ws_cull_vxrange.insert(wscfg.ws_cull_vxrange_sites.begin(), wscfg.ws_cull_vxrange_sites.end());
ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end());
ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end());
ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end());
ws_cull_nclip_keep.insert(wscfg.ws_cull_nclip_keep_sites.begin(), wscfg.ws_cull_nclip_keep_sites.end());
ws_cull_nclip_exact.insert(wscfg.ws_cull_nclip_exact_sites.begin(), wscfg.ws_cull_nclip_exact_sites.end());
ws_cull_branch_keep.insert(wscfg.ws_cull_branch_keep_sites.begin(), wscfg.ws_cull_branch_keep_sites.end());
ws_cull_bgez.insert(wscfg.ws_cull_bgez_sites.begin(), wscfg.ws_cull_bgez_sites.end());
ws_cull_clip_edge_x_load.insert(wscfg.ws_cull_clip_edge_x_load_sites.begin(),
wscfg.ws_cull_clip_edge_x_load_sites.end());
if (!wscfg.ws_cull_clip_edge_x_load_sites.empty() && !ws_cull_clip_edge_width)
ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(wscfg);
if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites;
if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites;
if (ws_aspect_cone.sites.empty())
ws_aspect_cone = wscfg.ws_aspect_cone;
ws_cull_activation_guard_pixels =
wscfg.ws_cull_activation_guard_pixels;
ws_cull_w_imms = wscfg.ws_cull_w_imms;
ws_cull_h_imms = wscfg.ws_cull_h_imms;
ws_backdrop_x.insert(wscfg.ws_backdrop_x_sites.begin(), wscfg.ws_backdrop_x_sites.end());
ws_backdrop_unsquash.insert(wscfg.ws_backdrop_unsquash_funcs.begin(), wscfg.ws_backdrop_unsquash_funcs.end());
ws_auto_screen_x_cull = ws_auto_screen_x_cull || wscfg.ws_auto_screen_x_cull;
ws_auto_backdrop_preload = ws_auto_backdrop_preload || wscfg.ws_auto_backdrop_preload;
PSXRecompV4::merge_recompiler_patches(
instruction_patches, wscfg.recompiler_patches);
ws_signed_x_bound_sites.insert(ws_signed_x_bound_sites.end(),
wscfg.ws_signed_x_bound_sites.begin(),
wscfg.ws_signed_x_bound_sites.end());
if (wscfg.ws_bg2d_init_func) ws_bg2d_init_func = wscfg.ws_bg2d_init_func;
fmt::print("ws-config: {} (backdrop_x sites={}, unsquash funcs={})\n",
ws_config_path.string(), ws_backdrop_x.size(), ws_backdrop_unsquash.size());
}
/* Resolve the BIOS profile this game builds against and activate its
* address model for codegen (jump tables inside relocated BIOS code
* windows fold through it — see code_generator.cpp ram_to_rom). Order:
* explicit [recompiler] bios_config; else the SCPH1001 profile in-repo
* (framework checkout) or under the psxrecomp/ submodule (game repo).
* No profile -> fatal: there are no built-in windows. */
static PSXRecompV4::BiosAddressModel s_bios_model;
{
/* The directory the profile probe walks. --project-root when given,
* else the CWD (unchanged legacy behaviour). Named in every failure
* below, because "no BIOS profile found" without the directory it
* searched sends you looking for an unset key that is in fact set —
* or, worse, reads as a config error when it is a CWD error. */
const std::filesystem::path search_root =
project_root.empty() ? std::filesystem::path(".") : project_root;
std::filesystem::path prof = bios_profile_path;
/* An explicit bios_config that does not resolve is a different failure
* from having none at all — say which, or the message sends you looking
* for an unset key that is in fact set. A relative bios_config is
* documented as relative to the game project root, so resolve it there
* first when we were told what the root is; fall back to the bare path
* so callers that already relied on CWD keep working. */
if (!prof.empty() && !std::filesystem::exists(prof)) {
std::filesystem::path rooted;
if (!project_root.empty() && prof.is_relative()) {
rooted = project_root / prof;
}
if (!rooted.empty() && std::filesystem::exists(rooted)) {
prof = rooted;
} else {
fmt::print(stderr,
"psxrecomp-game: FATAL: [recompiler] bios_config = '{}' does "
"not exist (path is relative to the game project root; "
"searched '{}')\n",
prof.string(),
std::filesystem::absolute(search_root).string());
return 1;
}
}
if (prof.empty()) {
if (std::filesystem::exists(search_root / "bios/SCPH1001.toml")) {
prof = search_root / "bios/SCPH1001.toml"; /* framework checkout */
} else {
/* Game repo vendoring the framework. Do NOT assume the
* directory is named "psxrecomp": ApeEscapeRecomp vendors it as
* "psxrecomp-v4", and hardcoding the name made regeneration
* fail outright there. Probe one level for any vendored
* framework, sorted so the pick is deterministic. */
std::vector<std::filesystem::path> found;
std::error_code ec;
for (const auto& de :
std::filesystem::directory_iterator(search_root, ec)) {
if (ec) break;
if (!de.is_directory(ec) || ec) continue;
auto cand = de.path() / "bios" / "SCPH1001.toml";
if (std::filesystem::exists(cand)) found.push_back(cand);
}
std::sort(found.begin(), found.end());
if (!found.empty()) prof = found.front();
}
}
if (prof.empty()) {
fmt::print(stderr,
"psxrecomp-game: FATAL: no BIOS profile found. Searched '{}' "
"for bios/SCPH1001.toml and <framework>/bios/SCPH1001.toml. "
"Fix by setting [recompiler] bios_config in game.toml, passing "
"--project-root <dir> (the framework or game-project root), or "
"running from a root that contains one of those paths.\n",
std::filesystem::absolute(search_root).string());
return 1;
}
const auto bios_cfg = PSXRecompV4::load_bios_config(prof);
s_bios_model = PSXRecompV4::BiosAddressModel::from_config(bios_cfg);
PSXRecomp::CodeGenerator::set_bios_address_model(&s_bios_model);
}
// Parse the PS1-EXE file
std::string error_msg;
fmt::print("Parsing PS1-EXE: {}\n", exe_path.string());
auto exe = PSXRecomp::PS1ExeParser::parse_file(exe_path, error_msg);
if (!exe.has_value()) {
fmt::print(stderr, "Failed to parse PS1-EXE: {}\n", error_msg);
return 1;
}
// [game].text_size is the title-owned static-analysis bound. It may trim a
// verified data/padding tail, but it may never widen the parsed payload or
// exclude the executable entry. Runtime disc loading remains unchanged.
if (!config_path.empty()) {
const uint32_t parsed_size = exe->header.file_size;
std::string bound_error;
if (!PSXRecomp::apply_static_analysis_bound(
*exe, configured_text_size, bound_error)) {
fmt::print(stderr, "Invalid static analysis bound: {}\n", bound_error);
return 1;
}
if (exe->header.file_size < parsed_size) {
fmt::print("Static analysis bound: 0x{:X} bytes from game.text_size "
"(PS-X EXE header: 0x{:X})\n",
exe->header.file_size, parsed_size);
}
}
// Patch the bounded image before discovery/CFG analysis so replacements of
// control-flow instructions cannot disagree with the generated graph.
PSXRecompV4::apply_recompiler_patches_to_executable(
*exe, instruction_patches, overlay_mode);
fmt::print("✓ Successfully parsed PS1-EXE!\n\n");
// Print header information
fmt::print("PS1-EXE Header Info:\n");
fmt::print(" Entry Point: 0x{:08X}\n", exe->header.initial_pc);
fmt::print(" Load Address: 0x{:08X}\n", exe->header.load_address);
fmt::print(" Code Size: {} bytes ({} KB)\n",
exe->header.file_size, exe->header.file_size / 1024);
fmt::print(" End Address: 0x{:08X}\n", exe->end_address());
if (exe->has_analysis_guard()) {
// Only printed for a producer-tagged capture, so a non-overlay image's
// log stays byte-identical to the untagged build.
fmt::print(" Analysis End: 0x{:08X} ({} trailing delay-slot guard "
"byte(s), readable but not discoverable)\n",
exe->analysis_end_address(), exe->analysis_guard_bytes);
}
fmt::print(" Global Pointer: 0x{:08X}\n", exe->header.initial_gp);
fmt::print(" Stack Pointer: 0x{:08X}\n\n", exe->header.initial_sp);
// Validate entry point
if (!exe->header.entry_in_range()) {
fmt::print(stderr, "⚠ Warning: Entry point 0x{:08X} is outside loaded code range!\n\n",
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 are not checked: a capture view is
// wrapped at `segment | phys` in whichever segment it ran (§5.7), and the
// game config, passed as --ws-config, names overlay code by its bytes, so
// overlay_codegen_config() below moves each exact-match site that lies in
// the image into the view's segment.
if (!overlay_mode) {
std::vector<std::pair<const char*, uint32_t>> 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<uint32_t>& 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);
for (const auto& site : draw_distance_clamps)
check("draw_distance.clamp.address", site.address);
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);
fmt::print("---------------------------------------\n");
uint32_t current_addr = exe->header.initial_pc;
const int num_instructions = 20;
for (int i = 0; i < num_instructions; i++) {
auto word_opt = exe->read_word(current_addr);
if (!word_opt.has_value()) {
fmt::print(" [End of code or read error]\n");
break;
}
uint32_t instr_word = *word_opt;
// Use Rabbitizer to disassemble the instruction
// Note: Rabbitizer expects big-endian, PS1 is little-endian, so the word is already in correct format
rabbitizer::InstructionCpu instr(instr_word, current_addr);
// Get disassembly (parameter is flags, 0 = default)
std::string disasm = instr.disassemble(0);
// Print address, hex, and disassembly
fmt::print(" {:08X}: {:08X} {}\n", current_addr, instr_word, disasm);
current_addr += 4;
}
fmt::print("\n");
fmt::print("✓ Disassembly complete!\n");
// Instruction frequency analysis
fmt::print("\n");
fmt::print("Analyzing instruction frequency...\n");
std::map<std::string, int> instr_freq;
std::map<uint32_t, int> opcode_freq;
std::map<uint32_t, int> special_funct_freq;
uint32_t total_instructions = 0;
uint32_t addr = exe->header.load_address;
while (addr < exe->end_address()) {
auto word_opt = exe->read_word(addr);
if (!word_opt.has_value()) break;
uint32_t instr_word = *word_opt;
rabbitizer::InstructionCpu instr(instr_word, addr);
std::string mnemonic = instr.getOpcodeName();
instr_freq[mnemonic]++;
total_instructions++;
// Track opcode distribution
uint32_t opcode = (instr_word >> 26) & 0x3F;
opcode_freq[opcode]++;
// Track SPECIAL function codes
if (opcode == 0x00) {
uint32_t funct = instr_word & 0x3F;
special_funct_freq[funct]++;
}
addr += 4;
}
fmt::print("Total instructions: {}\n\n", total_instructions);
// Sort by frequency and print top 20
std::vector<std::pair<std::string, int>> sorted_freq(instr_freq.begin(), instr_freq.end());
std::sort(sorted_freq.begin(), sorted_freq.end(),
[](const auto& a, const auto& b) { return a.second > b.second; });
fmt::print("=== Top 20 Most Common Instructions ===\n\n");
fmt::print("{:4s} {:12s} {:10s} {:s}\n", "Rank", "Instruction", "Count", "Percentage");
fmt::print("-----------------------------------------------------\n");
for (size_t i = 0; i < std::min(size_t(20), sorted_freq.size()); i++) {
const auto& [instr_name, count] = sorted_freq[i];
double pct = 100.0 * count / total_instructions;
fmt::print("{:4d} {:12s} {:10d} {:5.2f}%\n", i + 1, instr_name, count, pct);
}
fmt::print("\n");
}
// Function boundary detection
fmt::print("Performing function analysis...\n");
std::vector<uint32_t> exact_entries;
exact_entries.push_back(exe->header.initial_pc);
// Explicit entry points: functions called through pointers but not
// automatically detected by the heuristic-based function analysis.
// Pass game-specific entry points via --extra-funcs file.
/* Load extra function addresses from a file (one hex address per line).
* Lines of the form `interior 0xXXXXXXXX` mark dispatch-proven interior
* addresses: alias candidates, never walk roots. `hosted_interior TARGET
* HOST` is a narrow cross-variant identity assertion: TARGET may alias only
* the explicitly rooted HOST, and only when TARGET is a syntactic block
* leader in HOST's current-byte range. The shard publisher additionally
* requires both emitted identities to match a previously CRC-validated host
* exactly before the result can reach the runtime. Lines of the form
* `dispatch_root 0xXXXXXXXX` mark classifier-proven dispatch roots
* WITHOUT a callable boundary (the kernel install-slot class, e.g. RAM
* 0xCF0): the static recompiler's install-slot hooks tail-dispatch into
* exactly these PCs, so they are real execution roots even though no
* prologue / preceding jr $ra exists. They are trusted walk roots and
* exempt from the overlay-mode boundary re-check below. Lines of the
* form `call_root 0xXXXXXXXX` carry the same mechanical trust for a
* statically proven direct or constant-register call/tail-call target.
*
* 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.
*
* 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): it is never folded
* into the home body; after the home compile, the home functions it
* reaches through direct edges are compiled again in its segment (see
* plan_segment_variants below). Seeds outside the image are counted. */
std::vector<uint32_t> file_seeds;
std::vector<std::pair<uint32_t, std::string>> variant_seeds; // PC, line kind
size_t outside_seeds = 0;
std::vector<uint32_t> interior_seeds;
std::map<uint32_t, uint32_t> hosted_interior_seeds;
std::set<uint32_t> hosted_interior_hosts;
constexpr size_t kHostedInteriorTargetCap = 4096;
constexpr size_t kHostedInteriorHostCap = 512;
std::set<uint32_t> trusted_root_seeds;
std::set<uint32_t> trusted_call_root_seeds;
std::vector<std::pair<uint32_t, uint32_t>> producer_ranges;
std::set<uint32_t> cross_call_allow;
if (extra_funcs_path) {
std::ifstream ef(extra_funcs_path);
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<uint32_t> 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;
if (line.rfind("producer_range", 0) == 0) {
std::istringstream in(line);
std::string tag, lo_text, hi_text;
in >> tag >> lo_text >> hi_text;
uint32_t lo = static_cast<uint32_t>(
std::strtoul(lo_text.c_str(), nullptr, 16));
uint32_t hi = static_cast<uint32_t>(
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);
return 1;
}
producer_ranges.emplace_back(lo, hi);
continue;
}
if (line.rfind("cross_call_allow", 0) == 0) {
const char* p = line.c_str() + 16;
uint32_t addr = static_cast<uint32_t>(
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;
}
cross_call_allow.insert(addr);
continue;
}
if (line.rfind("retained_alias", 0) == 0) {
fmt::print(" ignoring legacy retained_alias; old host "
"ranges are never reused: {}\n", line);
continue;
}
if (line.rfind("hosted_interior", 0) == 0) {
std::istringstream in(line);
std::string tag, target_text, host_text, trailing;
in >> tag >> target_text >> host_text >> trailing;
auto parse_hex_u32 = [](const std::string& text,
uint32_t& value) -> bool {
if (text.empty()) return false;
char* end = nullptr;
errno = 0;
unsigned long long parsed =
std::strtoull(text.c_str(), &end, 16);
if (errno != 0 || end == text.c_str() || *end != '\0' ||
parsed > 0xFFFFFFFFull) return false;
value = static_cast<uint32_t>(parsed);
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) ||
(target & 3u) != 0u || (host & 3u) != 0u ||
target < seed_lo || target >= seed_hi ||
host < seed_lo || host >= seed_hi || target == host) {
fmt::print(stderr, "ERROR: invalid hosted_interior: {}\n", line);
return 1;
}
auto prior = hosted_interior_seeds.find(target);
if (prior != hosted_interior_seeds.end() &&
prior->second != host) {
fmt::print(stderr, "ERROR: conflicting hosted_interior "
"hosts for 0x{:08X}\n", target);
return 1;
}
hosted_interior_seeds.emplace(target, host);
hosted_interior_hosts.insert(host);
if (hosted_interior_seeds.size() >
kHostedInteriorTargetCap ||
hosted_interior_hosts.size() >
kHostedInteriorHostCap) {
fmt::print(stderr, "ERROR: hosted_interior capacity "
"exceeded (targets {} / {}, hosts {} / {})\n",
hosted_interior_seeds.size(),
kHostedInteriorTargetCap,
hosted_interior_hosts.size(),
kHostedInteriorHostCap);
return 1;
}
continue;
}
bool interior = false;
bool trusted_root = false;
bool trusted_call_root = false;
const char* p = line.c_str();
if (line.rfind("interior", 0) == 0) {
interior = true;
p += 8;
} else if (line.rfind("dispatch_root", 0) == 0) {
trusted_root = true;
p += 13;
} else if (line.rfind("call_root", 0) == 0) {
trusted_call_root = true;
p += 9;
}
uint32_t addr = (uint32_t)std::strtoul(p, nullptr, 16);
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 (!PSXRecomp::maps_physical(addr)) {
// Only KUSEG below 0x20000000, KSEG0 and KSEG1 alias the
// image's bytes; a variant in any other segment would
// give dispatch rows to PCs hardware cannot fetch as RAM.
fmt::print(stderr,
"ERROR: seed 0x{:08X} is in segment 0x{:08X}, which does not "
"map physical memory (only KUSEG below 0x20000000, KSEG0 and "
"KSEG1 do), so it names no code of this image and cannot ask "
"for a segment variant "
"(docs/SEGMENT_AWARE_CODE.md §5.4). The image's bytes at that "
"physical address are 0x{:08X} in its link segment.\n",
addr, addr & PSXRecomp::kSegmentMask,
exe->link_segment() | phys);
return 1;
} else if ((addr & PSXRecomp::kSegmentMask) != exe->link_segment()) {
variant_seeds.emplace_back(
addr, interior ? "interior" : trusted_root ? "dispatch_root"
: 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());
for (size_t i = 1; i < producer_ranges.size(); i++) {
if (producer_ranges[i - 1].second > producer_ranges[i].first) {
fmt::print(stderr, "ERROR: overlapping producer_range entries\n");
return 1;
}
}
for (uint32_t addr : cross_call_allow) {
bool contained = false;
for (const auto& range : producer_ranges) {
if (addr >= range.first && addr < range.second) {
contained = true;
break;
}
}
if (!contained) {
fmt::print(stderr,
"ERROR: cross_call_allow 0x{:08X} is outside "
"producer ranges\n", addr);
return 1;
}
}
fmt::print("Loaded {} extra function addresses ({} interior, "
"{} hosted-interior, {} dispatch-root, {} call-root, {} producer ranges, "
"{} cross-call allows) from {}\n",
file_seeds.size() + interior_seeds.size() +
hosted_interior_seeds.size(),
interior_seeds.size(), hosted_interior_seeds.size(),
trusted_root_seeds.size(),
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";
};
if (overlay_mode) {
fmt::print("WARNING: {} seed(s) name this image's bytes in another "
"segment than its link segment {}. Overlay compiles have "
"no segment variants yet (docs/SEGMENT_AWARE_CODE.md §5.7): "
"nothing is compiled for them.\n",
variant_seeds.size(), seg_name(exe->link_segment()));
} else {
fmt::print("{} seed(s) name this image's bytes in another segment than "
"its link segment {}. Each asks for a segment variant "
"(docs/SEGMENT_AWARE_CODE.md §5.4), compiled after the home "
"compile. A seed for the image's own code is written in {}.\n",
variant_seeds.size(), seg_name(exe->link_segment()),
seg_name(exe->link_segment()));
}
for (const auto& [addr, kind] : variant_seeds) {
fmt::print(" variant seed 0x{:08X} ({}, {}): home PC would be 0x{:08X}\n",
addr, kind, seg_name(addr & PSXRecomp::kSegmentMask),
exe->link_segment() | (addr & PSXRecomp::kPhysMask));
}
}
} else {
fmt::print("WARNING: Cannot open extra-funcs file: {}\n", extra_funcs_path);
}
}
PSXRecomp::FunctionAnalysisResult analysis_result;
if (overlay_mode || reachable_discovery) {
// ── Exact-entry mode: partition seeds into walk roots and interior
// alias candidates. `interior`-marked seeds (classifier-proven
// dispatch targets without a callable boundary) are NEVER walk roots —
// a root inside a host function hard-caps (truncates) it, the
// mid-function-seed softlock class. Unmarked seeds are re-verified
// against the same boundary rule as defense in depth.
const uint32_t exe_lo = exe->header.load_address;
auto read_w = [&](uint32_t a) -> uint32_t {
auto w = exe->read_word(a);
return w.has_value() ? *w : 0u;
};
auto dense_local_pointer_table = [&](uint32_t a) -> bool {
for (uint32_t i = 0; i < 3u; i++) {
auto value = exe->read_word(a + i * 4u);
if (!value.has_value() || (*value & 3u) != 0u ||
*value < exe_lo || *value >= exe->analysis_end_address()) {
return false;
}
}
return true;
};
auto callable_boundary = [&](uint32_t a) -> bool {
uint32_t w = read_w(a);
if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) return false;
if (a == exe_lo) return true;
if (a >= exe_lo + 8 && read_w(a - 8) == 0x03E00008u) return true;
// Normal-mode discovery already skips alignment padding after a
// return when it infers a frameless function start. Preserve that
// evidence here. Requiring the next entry to be exactly jr-ra+8
// demoted legitimate Psy-Q leaf routines separated by one to six
// padding NOPs to orphan interiors, so exact-entry analysis emitted
// no function at all. The delay slot (jr+4) may be non-NOP; only
// words after it and before the proposed entry must be padding.
bool padding_only = true;
for (uint32_t back = 12; back <= 32 && a >= exe_lo + back;
back += 4) {
if (read_w(a - back + 8) != 0u) padding_only = false;
if (!padding_only) break;
if (read_w(a - back) == 0x03E00008u) return true;
}
int32_t frame = 0;
return PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) &&
!(a >= exe_lo + 4 &&
PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4)));
};
std::set<uint32_t> roots;
std::set<uint32_t> interior;
std::set<uint32_t> hosted_targets;
for (const auto& seed : hosted_interior_seeds)
hosted_targets.insert(seed.first);
for (uint32_t a : exact_entries) {
if (hosted_targets.count(a)) {
// A fragment PS-X header needs one initial PC, but a trusted
// hosted interior must never become a walk root through it.
continue;
} else if (reachable_discovery && a == exe->header.initial_pc) {
// The PS-X EXE header is direct execution evidence for the
// main entry even when it uses a nonstandard prologue.
roots.insert(a);
} else if (trusted_root_seeds.count(a)) {
/* Classifier-proven dispatch root (install-slot class): the
* static code tail-dispatches into this PC, so it is a real
* execution root despite having no callable boundary. */
fmt::print(" seed 0x{:08X} accepted as dispatch root "
"(install-slot class, no boundary evidence)\n", a);
roots.insert(a);
} else if (trusted_call_root_seeds.count(a) &&
!dense_local_pointer_table(a)) {
fmt::print(" seed 0x{:08X} accepted as static call root "
"(direct/constant-register target)\n", a);
roots.insert(a);
} else if (callable_boundary(a)) {
roots.insert(a);
} else {
fmt::print(" seed 0x{:08X} fails the boundary check — "
"treating as interior alias candidate\n", a);
interior.insert(a);
}
}
for (uint32_t a : interior_seeds) interior.insert(a);
auto containing = [&](uint32_t a) -> const PSXRecomp::Function* {
const PSXRecomp::Function* best = nullptr;
for (const auto& f : analysis_result.functions) {
if (a >= f.start_addr && a < f.end_addr) { best = &f; break; }
}
return best;
};
// Interior candidates attach only to functions the EVIDENCE-walked
// root set already owns. No backward-boundary guessing: a false
// prologue/jr-ra in embedded data mints a root that decodes data as
// code (audit-fatal). Orphans stay with the interpreter — it
// self-heals via recapture once real call evidence appears.
{
PSXRecomp::FunctionAnalyzer analyzer(*exe);
std::vector<uint32_t> roots_vec(roots.begin(), roots.end());
analysis_result = analyzer.analyze_exact_entries(
roots_vec, producer_ranges, cross_call_allow);
}
std::vector<AliasEntry> alias_entries;
std::set<uint32_t> alias_seen;
for (uint32_t a : interior) {
const PSXRecomp::Function* host = containing(a);
if (host && a == host->start_addr) continue; // became a real entry
if (host && analysis_result.exact_reachable_pcs.count(a)) {
if (alias_seen.insert(a).second)
alias_entries.push_back({a, host->start_addr, host->end_addr});
} else {
fmt::print(" WARNING: interior seed 0x{:08X} has no host "
"function — left to the interpreter\n", a);
}
}
// Cross-variant propagation never guesses a new walk. The caller names
// a current-byte host that was already CRC-valid in the cache, and the
// recompiler requires that exact root plus a syntactic block boundary
// in the host's current-byte range. This block test alone is NOT proof
// of reachability: the publisher must compare the emitted host+alias
// CRC/range identities with the preselected cached owner before use.
PSXRecomp::ControlFlowAnalyzer hosted_cfg_analyzer(*exe);
std::map<uint32_t, PSXRecomp::ControlFlowGraph> hosted_cfgs;
for (const auto& seed : hosted_interior_seeds) {
uint32_t target = seed.first;
uint32_t requested_host = seed.second;
const PSXRecomp::Function* host = nullptr;
for (const auto& function : analysis_result.functions) {
if (function.start_addr == requested_host) {
host = &function;
break;
}
}
bool explicitly_rooted = roots.count(requested_host) != 0;
bool producer_ok = host &&
(host->producer_lo == 0 ||
(target >= host->producer_lo && target < host->producer_hi));
bool block_ok = false;
if (host && producer_ok && target > host->start_addr &&
target < host->end_addr) {
auto inserted = hosted_cfgs.emplace(
host->start_addr, PSXRecomp::ControlFlowGraph{});
if (inserted.second)
inserted.first->second = hosted_cfg_analyzer.analyze_function(*host);
block_ok = inserted.first->second.blocks.count(target) != 0;
}
if (host && explicitly_rooted && producer_ok && block_ok) {
if (alias_seen.insert(target).second)
alias_entries.push_back(
{target, host->start_addr, host->end_addr});
} else {
fmt::print(" WARNING: hosted interior 0x{:08X} rejected for "
"host 0x{:08X} (host={}, rooted={}, producer={}, block={})\n",
target, requested_host, host ? "yes" : "no",
explicitly_rooted ? "yes" : "no",
producer_ok ? "yes" : "no", block_ok ? "yes" : "no");
}
}
// Range-ownership completeness uses only analyzer-proven transfers from
// reachable instructions. The analyzer also revalidates each target's
// host against the final root partition and producer boundaries.
size_t proven_alias_added = 0;
for (const auto& absorbed : analysis_result.absorbed_entries) {
if (!alias_seen.insert(absorbed.addr).second) continue;
alias_entries.push_back(
{absorbed.addr, absorbed.host_start, absorbed.host_end});
proven_alias_added++;
}
if (proven_alias_added)
fmt::print(" +{} analyzer-proven absorbed alias entries "
"(final range ownership)\n", proven_alias_added);
materialize_alias_groups(analysis_result, alias_entries);
fmt::print("Exact-entry alias entries emitted: {}\n\n", alias_entries.size());
} else {
// ── Iterative discovery: seed classification + static data-table scan ──
//
// Every candidate entry (explicit seed or scanned data pointer) is
// classified against the current function set:
// * matches a function start → already covered
// * INSIDE a function's range → ALIAS entry (overlapping
// emission; the host is never capped/truncated — forcing such an
// entry is the mid-function-seed softlock class)
// * in a gap between functions → forced entry (explicit
// seeds are trusted; scanned pointers additionally need boundary
// evidence: a jr $ra two slots earlier or a non-delay-slot
// prologue)
//
// The data scan walks every word of the image OUTSIDE code-function
// ranges (pointer tables live in data regions) and collects words that
// point at valid instructions inside code-function ranges. Promotions
// change function boundaries, so iterate to a fixed point.
std::vector<AliasEntry> alias_entries;
std::set<uint32_t> forced;
forced.insert(exe->header.initial_pc);
const uint32_t exe_lo = exe->header.load_address;
const uint32_t exe_hi = exe->analysis_end_address();
auto read_w = [&](uint32_t a) -> uint32_t {
auto w = exe->read_word(a);
return w.has_value() ? *w : 0u;
};
const int kMaxDiscoveryIters = 6;
for (int iter = 0; iter < kMaxDiscoveryIters; ++iter) {
PSXRecomp::FunctionAnalyzer analyzer(*exe);
for (uint32_t a : forced) analyzer.add_forced_entry(a);
analysis_result = analyzer.analyze();
std::vector<const PSXRecomp::Function*> code_funcs;
for (const auto& f : analysis_result.functions) {
if (!f.is_data_section) code_funcs.push_back(&f);
}
auto containing = [&](uint32_t a) -> const PSXRecomp::Function* {
auto it = std::upper_bound(code_funcs.begin(), code_funcs.end(), a,
[](uint32_t v, const PSXRecomp::Function* f) { return v < f->start_addr; });
if (it == code_funcs.begin()) return nullptr;
--it;
return (a >= (*it)->start_addr && a < (*it)->end_addr) ? *it : nullptr;
};
struct Candidate { uint32_t addr; bool trusted; bool table_evidence; };
std::vector<Candidate> candidates;
for (uint32_t s : file_seeds) candidates.push_back({s, true, true});
for (uint32_t s : interior_seeds) candidates.push_back({s, true, true});
uint32_t scanned_words = 0;
auto is_text_ptr = [&](uint32_t v) {
return (v & 3u) == 0 && v >= exe_lo && v < exe_hi && containing(v) != nullptr;
};
for (uint32_t p = exe_lo; p + 4 <= exe_hi; p += 4) {
if (containing(p)) continue; // instruction word, not a data word
uint32_t w = read_w(p);
if ((w & 3u) || w < exe_lo || w >= exe_hi) continue;
// Table evidence: an adjacent data word that is also a text
// pointer. Interior handler entries live in dense pointer
// tables; a lone pointer-shaped data word is too weak to mint
// an alias from (random data aliases into loose code ranges).
bool table_evidence =
(p >= exe_lo + 4 && is_text_ptr(read_w(p - 4))) ||
(p + 8 <= exe_hi && is_text_ptr(read_w(p + 4)));
candidates.push_back({w, false, table_evidence});
scanned_words++;
}
alias_entries.clear();
std::set<uint32_t> alias_seen;
bool promoted_new = false;
for (const auto& [a, trusted, table_evidence] : candidates) {
if ((a & 3u) || a < exe_lo || a >= exe_hi) continue;
const PSXRecomp::Function* host = containing(a);
if (host && a == host->start_addr) continue; // already an entry
uint32_t w = read_w(a);
if (!PSXRecomp::FunctionAnalyzer::is_valid_mips_word(w)) continue;
if (host) {
if ((trusted || table_evidence) && alias_seen.insert(a).second) {
alias_entries.push_back({a, host->start_addr, host->end_addr});
}
continue;
}
bool promote = trusted;
if (!promote) {
bool after_ret = (a >= exe_lo + 8) && (read_w(a - 8) == 0x03E00008u);
int32_t frame = 0;
bool prologue =
PSXRecomp::FunctionAnalyzer::is_prologue(w, frame) &&
!(a >= exe_lo + 4 &&
PSXRecomp::FunctionAnalyzer::is_branch_or_jump(read_w(a - 4)));
promote = after_ret || prologue;
}
if (promote && forced.insert(a).second) promoted_new = true;
}
fmt::print("Discovery iteration {}: {} data pointers scanned, "
"{} forced entries, {} alias entries\n",
iter + 1, scanned_words, forced.size(), alias_entries.size());
if (!promoted_new) break;
}
materialize_alias_groups(analysis_result, alias_entries);
fmt::print("Alias entries emitted: {}\n\n", alias_entries.size());
}
// Print summary statistics
fmt::print("\n=== Function Analysis Summary ===\n\n");
fmt::print("Total Functions: {}\n", analysis_result.functions.size());
fmt::print("Code Coverage: {} KB analyzed\n",
(exe->end_address() - exe->header.load_address) / 1024);
// Count functions with prologues/epilogues
int with_prologue = 0;
int with_epilogue = 0;
int with_both = 0;
for (const auto& func : analysis_result.functions) {
if (func.has_prologue) with_prologue++;
if (func.has_epilogue) with_epilogue++;
if (func.has_prologue && func.has_epilogue) with_both++;
}
fmt::print("With Prologue: {} ({:.1f}%)\n", with_prologue,
100.0 * with_prologue / analysis_result.functions.size());
fmt::print("With Epilogue: {} ({:.1f}%)\n", with_epilogue,
100.0 * with_epilogue / analysis_result.functions.size());
fmt::print("Complete: {} ({:.1f}%)\n\n", with_both,
100.0 * with_both / analysis_result.functions.size());
// Print first 20 functions
fmt::print("First 20 Functions:\n");
fmt::print("---------------------------------------\n");
for (size_t i = 0; i < std::min(size_t(20), analysis_result.functions.size()); i++) {
const auto& func = analysis_result.functions[i];
fmt::print(" {:08X}-{:08X} {:5} bytes {}{}\n",
func.start_addr,
func.end_addr,
func.size,
func.name,
func.has_prologue ? fmt::format(" (frame={})", func.stack_frame_size) : "");
}
fmt::print("\n... ({} more functions)\n\n",
analysis_result.functions.size() - std::min(size_t(20), analysis_result.functions.size()));
// Control flow analysis
PSXRecomp::ControlFlowAnalyzer cfg_analyzer(*exe);
auto all_cfgs = cfg_analyzer.analyze_all_functions(analysis_result.functions);
// Print CFG summary statistics
fmt::print("=== Control Flow Summary ===\n\n");
int total_basic_blocks = 0;
int total_loops = 0;
int total_branches = 0;
for (const auto& [func_addr, cfg] : all_cfgs) {
total_basic_blocks += static_cast<int>(cfg.blocks.size());
total_loops += cfg.loop_count;
// Count branch instructions across all blocks
for (const auto& [block_addr, block] : cfg.blocks) {
if (block.exit_instr.type == PSXRecomp::ControlFlowType::Branch) {
total_branches++;
}
}
}
fmt::print("Basic Blocks: {}\n", total_basic_blocks);
fmt::print("Loops Detected: {}\n", total_loops);
fmt::print("Branch Instructions: {}\n", total_branches);
fmt::print("Avg Blocks/Function: {:.1f}\n\n",
static_cast<double>(total_basic_blocks) / all_cfgs.size());
// Show detailed CFG for first function (as an example)
if (!analysis_result.functions.empty() && !all_cfgs.empty()) {
const auto& first_func = analysis_result.functions[0];
const auto& first_cfg = all_cfgs.at(first_func.start_addr);
fmt::print("Example: CFG for {} (0x{:08X}):\n", first_func.name, first_func.start_addr);
fmt::print("---------------------------------------\n");
fmt::print(" Blocks: {}\n", first_cfg.blocks.size());
fmt::print(" Loops: {}\n", first_cfg.loop_count);
if (first_cfg.blocks.size() > 1) {
fmt::print("\n Block Details:\n");
int count = 0;
for (const auto& block_addr : first_cfg.block_order) {
const auto& block = first_cfg.blocks.at(block_addr);
fmt::print(" Block 0x{:08X}: {} instructions", block.start_addr, block.instruction_count);
if (block.is_entry) fmt::print(" [ENTRY]");
if (block.is_exit) fmt::print(" [EXIT]");
if (block.is_loop_header) fmt::print(" [LOOP]");
fmt::print("\n");
fmt::print(" Successors: {}", block.successors.size());
if (!block.successors.empty()) {
fmt::print(" (");
for (size_t i = 0; i < block.successors.size(); i++) {
fmt::print("0x{:08X}", block.successors[i]);
if (i + 1 < block.successors.size()) fmt::print(", ");
}
fmt::print(")");
}
fmt::print("\n");
// Only show first 5 blocks for brevity
if (++count >= 5 && first_cfg.blocks.size() > 5) {
fmt::print(" ... ({} more blocks)\n", first_cfg.blocks.size() - 5);
break;
}
}
}
fmt::print("\n");
}
// C code generation
PSXRecomp::CodeGenConfig codegen_config;
codegen_config.emit_comments = true;
codegen_config.emit_line_numbers = true;
codegen_config.split_mid_function_targets = !overlay_mode;
codegen_config.overlay_mode = overlay_mode;
codegen_config.ws_signed_x_bound_sites = ws_signed_x_bound_sites;
codegen_config.ws_sprite_tag_funcs = ws_tag_funcs;
codegen_config.data_shard_funcs = ds_funcs;
codegen_config.mod_function_entry_funcs = mod_entry_funcs;
codegen_config.hot_funcs = hot_funcs;
codegen_config.load_charge_batch_funcs = load_charge_batch_funcs;
codegen_config.vsync_query_hle_funcs = vsync_query_hle_funcs;
codegen_config.ws_bg2d_init_func = ws_bg2d_init_func;
codegen_config.ws_cull_bias_sites = ws_cull_bias;
codegen_config.ws_cull_bias_lower_sites = ws_cull_bias_lower;
codegen_config.ws_cull_range_sites = ws_cull_range;
codegen_config.ws_cull_a1_sites = ws_cull_a1;
codegen_config.ws_cull_screen_x_sites = ws_cull_screen_x;
codegen_config.ws_cull_activation_guard_pixels =
ws_cull_activation_guard_pixels;
codegen_config.ws_cull_slti_sites = ws_cull_slti;
codegen_config.ws_cull_slti_lower_sites = ws_cull_slti_lower;
codegen_config.ws_cull_bltz_sites = ws_cull_bltz;
codegen_config.ws_cull_negsub_sites = ws_cull_negsub;
codegen_config.ws_cull_vxrange_sites = ws_cull_vxrange;
codegen_config.ws_cull_depth_sites = ws_cull_depth;
codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx;
codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load;
codegen_config.ws_cull_nclip_keep_sites = ws_cull_nclip_keep;
codegen_config.ws_cull_nclip_exact_sites = ws_cull_nclip_exact;
codegen_config.ws_cull_branch_keep_sites = ws_cull_branch_keep;
codegen_config.ws_cull_bgez_sites = ws_cull_bgez;
codegen_config.ws_cull_clip_edge_x_load_sites = ws_cull_clip_edge_x_load;
if (ws_cull_clip_edge_width)
codegen_config.ws_cull_clip_edge_width = ws_cull_clip_edge_width;
else if (!ws_cull_w_imms.empty())
codegen_config.ws_cull_clip_edge_width = ws_cull_w_imms.front();
codegen_config.ws_cull_keep_sites = ws_cull_keep;
codegen_config.ws_cull_angle_sites = ws_cull_angle;
codegen_config.draw_distance_clamp_sites = draw_distance_clamps;
codegen_config.ws_aspect_cone = ws_aspect_cone;
codegen_config.ws_cull_w_imms = ws_cull_w_imms;
codegen_config.ws_cull_h_imms = ws_cull_h_imms;
codegen_config.ws_backdrop_x_sites = ws_backdrop_x;
codegen_config.ws_backdrop_unsquash_funcs = ws_backdrop_unsquash;
codegen_config.ws_auto_screen_x_cull = ws_auto_screen_x_cull;
codegen_config.persist_init_store_sites = persist_init_sites;
codegen_config.ws_auto_backdrop_preload = ws_auto_backdrop_preload;
codegen_config.ws_bg2d_count_site = ws_bg2d_count_site;
codegen_config.ws_bg2d_startcol_site = ws_bg2d_startcol_site;
codegen_config.ws_bg2d_startx_site = ws_bg2d_startx_site;
codegen_config.ws_bg2d_stream_left_site = ws_bg2d_stream_left_site;
codegen_config.ws_bg2d_stream_right_site = ws_bg2d_stream_right_site;
codegen_config.ws_bg2d_bufbase_site = ws_bg2d_bufbase_site;
codegen_config.ws_bg2d_cap_site = ws_bg2d_cap_site;
if (ws_bg2d_count_site || ws_bg2d_startcol_site || ws_bg2d_startx_site)
fmt::print(" ws_bg2d 2D-background widen = ON (count=0x{:08X} startcol=0x{:08X} startx=0x{:08X})\n",
ws_bg2d_count_site, ws_bg2d_startcol_site, ws_bg2d_startx_site);
if (ws_auto_screen_x_cull)
fmt::print(" ws_auto_screen_x_cull = ON (render-funnel FOV widening)\n");
if (!persist_init_sites.empty())
fmt::print(" persist_options = {} init-store hook(s)\n", persist_init_sites.size());
if (ws_auto_backdrop_preload)
fmt::print(" ws_auto_backdrop_preload = ON (far-backdrop column preload)\n");
// Load per-game annotations: annotations/<exe_stem>_annotations.csv
// Silently skipped if the file doesn't exist.
PSXRecomp::AnnotationTable annotations;
{
std::string stem = exe_path.filename().string();
std::string ann_path = "annotations/" + stem + "_annotations.csv";
if (annotations.load(ann_path.c_str()))
fmt::print("✓ Loaded {} annotations from {}\n\n", annotations.count(), ann_path);
else
fmt::print(" (No annotations file found at {})\n\n", ann_path);
}
// Overlay capture views (docs/SEGMENT_AWARE_CODE.md §5.7): every view of
// the same bytes gets the same hooks and substitutions, whatever segment
// it runs in and however the config spells the site.
if (overlay_mode)
codegen_config = PSXRecomp::overlay_codegen_config(codegen_config, *exe);
PSXRecomp::CodeGenerator codegen(*exe, codegen_config);
codegen.set_annotations(&annotations);
// Generate code for first 5 functions (as examples)
fmt::print("=== C Code Generation Examples ===\n\n");
fmt::print("Generating code for first 5 functions...\n\n");
std::vector<PSXRecomp::Function> sample_funcs;
for (size_t i = 0; i < std::min(size_t(5), analysis_result.functions.size()); i++) {
sample_funcs.push_back(analysis_result.functions[i]);
}
auto generated = codegen.generate_all_functions(sample_funcs, all_cfgs);
// Print first generated function as example
if (!generated.empty()) {
fmt::print("Example Generated Function:\n");
fmt::print("---------------------------------------\n");
fmt::print("{}\n", generated[0].full_code);
fmt::print("---------------------------------------\n\n");
}
// Generate full C file and save to the generated directory
// Use argv[2] as output path if provided, otherwise derive from input filename
// Use filename() not stem() because ".36" in SCUS_942.36 is part of the serial, not an extension
std::string exe_stem = exe_path.filename().string();
std::filesystem::create_directories(out_dir);
std::filesystem::path output_filename = out_dir / (exe_stem + "_full.c");
fmt::print("Generating complete C file: {}\n", output_filename.string());
std::string full_c_code = codegen.generate_file(analysis_result.functions, all_cfgs);
std::set<uint32_t> dispatch_addrs;
{
const std::string marker = "void func_";
size_t pos = 0;
while ((pos = full_c_code.find(marker, pos)) != std::string::npos) {
size_t hex_pos = pos + marker.size();
if (hex_pos + 8 <= full_c_code.size() &&
full_c_code.compare(hex_pos + 8, 14, "(CPUState* cpu") == 0) {
std::string hex = full_c_code.substr(hex_pos, 8);
dispatch_addrs.insert((uint32_t)std::strtoul(hex.c_str(), nullptr, 16));
}
pos = hex_pos + 8;
}
}
// Data/untranslatable entries are emitted as fail-closed
// psx_unknown_dispatch stubs. Do not put those stubs in the callable game
// dispatch table: a runtime overlay can legitimately replace the same EXE
// address, and a tiny stub range (especially a single NOP) can
// coincidentally byte-match the live overlay continuation. Advertising the
// stub as native-safe then turns valid dynamic code into a false fail-fast.
for (const auto& gen_func : codegen.last_gen_funcs()) {
if (gen_func.dispatchable) continue;
const std::string prefix = "func_";
if (gen_func.function_name.rfind(prefix, 0) != 0 ||
gen_func.function_name.size() < prefix.size() + 8) {
continue;
}
const std::string hex =
gen_func.function_name.substr(prefix.size(), 8);
dispatch_addrs.erase(
(uint32_t)std::strtoul(hex.c_str(), nullptr, 16));
}
if (dispatch_addrs.empty() && codegen.last_gen_funcs().empty()) {
for (const auto& func : analysis_result.functions) {
dispatch_addrs.insert(func.start_addr);
}
}
// Segment variants (docs/SEGMENT_AWARE_CODE.md §5.4). Each segment named
// by a variant seed gets the direct-edge closure of the home functions
// its seeds reach, compiled again through a view of the image in that
// segment: its names, rows and every PC it bakes are that segment's, and
// a KSEG1 variant charges a fetch per instruction (§5.6). The bodies join
// the home shards, and their rows join the home dispatch table. Overlay
// compiles have none yet (§5.7).
std::vector<PSXRecomp::GeneratedFunction> all_gen_funcs = codegen.last_gen_funcs();
std::vector<std::string> variant_alias_decls;
std::string all_ranges_manifest = codegen.last_ranges_manifest().empty()
? codegen.generate_ranges_manifest(analysis_result.functions, all_cfgs)
: codegen.last_ranges_manifest();
std::vector<PSXRecomp::GameDispatchUnit> dispatch_units{{&codegen, dispatch_addrs}};
std::vector<std::unique_ptr<PSXRecomp::PS1Executable>> variant_views;
std::vector<std::unique_ptr<PSXRecomp::CodeGenerator>> variant_codegens;
if (!overlay_mode && !variant_seeds.empty()) {
std::vector<uint32_t> requested;
for (const auto& [addr, kind] : variant_seeds) {
(void)kind;
requested.push_back(addr);
}
const auto plans = PSXRecomp::plan_segment_variants(
*exe, codegen.last_functions(), codegen.last_cfgs(),
codegen.cps_continuations(), requested);
for (const auto& plan : plans) {
fmt::print("\n=== Segment variant 0x{:08X} (docs/SEGMENT_AWARE_CODE.md §5.4) ===\n",
plan.segment);
for (const auto& [pc, why] : plan.unplaced) {
fmt::print(stderr,
"ERROR: variant seed 0x{:08X} cannot be compiled: {}. Seed the home "
"PC first, or drop the variant seed.\n", pc, why);
}
if (!plan.unplaced.empty()) return 1;
fmt::print(" {} seed(s), direct-edge closure of {} function(s)\n",
plan.seeds.size(), plan.functions.size());
variant_views.push_back(std::make_unique<PSXRecomp::PS1Executable>(
PSXRecomp::segment_view(*exe, plan.segment)));
const PSXRecomp::PS1Executable& view = *variant_views.back();
PSXRecomp::ControlFlowAnalyzer view_cfg(view);
const auto variant_cfgs = view_cfg.analyze_all_functions(plan.functions);
variant_codegens.push_back(std::make_unique<PSXRecomp::CodeGenerator>(
view, PSXRecomp::rebase_codegen_config(codegen_config, *exe, plan.segment)));
PSXRecomp::CodeGenerator& vgen = *variant_codegens.back();
(void)vgen.generate_file(plan.functions, variant_cfgs);
std::set<uint32_t> variant_addrs;
for (const auto& gf : vgen.last_gen_funcs()) {
all_gen_funcs.push_back(gf);
if (gf.dispatchable && gf.function_name.rfind("func_", 0) == 0 &&
gf.function_name.size() == 13)
variant_addrs.insert(static_cast<uint32_t>(
std::strtoul(gf.function_name.c_str() + 5, nullptr, 16)));
}
for (const auto& decl : vgen.alias_body_decls()) variant_alias_decls.push_back(decl);
// The variant's F/R records, without the manifest's header comment.
std::istringstream vr(vgen.last_ranges_manifest());
for (std::string line; std::getline(vr, line);) {
if (!line.empty() && line[0] != '#') all_ranges_manifest += line + "\n";
}
dispatch_units.push_back({&vgen, variant_addrs});
fmt::print(" {} bodies, {} dispatch entries\n",
vgen.last_gen_funcs().size(), variant_addrs.size());
}
}
// Split-TU output: instead of one monolithic <exe_stem>_full.c, write a
// shared declarations header plus N shards bucketed by cumulative line
// count, so the game build can compile the shards in parallel. The
// in-memory full_c_code / dispatch_addrs scan above is unaffected — only
// the on-disk representation changes. Function bodies are byte-identical
// to what full_c_code would have contained; see
// CodeGenerator::last_gen_funcs() / build_shared_decls_header().
if (overlay_mode) {
// Overlays are single small TUs consumed by compile_overlays.py, which
// reads <stem>_full.c directly. Emit the monolith; the split path below
// would delete _full.c and leave only shards, breaking overlay compile
// (no_output). Splitting a small overlay TU has no parallel-compile
// benefit anyway.
std::filesystem::path full_path(output_filename);
if (write_file_if_changed(full_path, full_c_code)) {
fmt::print("✓ Saved overlay monolith to {}\n", output_filename.string());
} else {
fmt::print("✓ Overlay monolith unchanged ({})\n", output_filename.string());
}
} else {
// 1. Shared declarations header (write-if-changed preserves Ninja mtimes).
std::filesystem::path decls_filename = out_dir / (exe_stem + "_decls.h");
{
const std::string decls =
codegen.build_shared_decls_header(all_gen_funcs, variant_alias_decls);
if (write_file_if_changed(decls_filename, decls)) {
fmt::print("✓ Saved shared decls header to {}\n", decls_filename.string());
} else {
fmt::print("✓ Shared decls header unchanged ({})\n", decls_filename.string());
}
}
// 2. Bucket generated functions into shards by cumulative line count.
const int SHARD_LINE_BUDGET = 40000;
const std::vector<PSXRecomp::GeneratedFunction>& gen_funcs_for_shards = all_gen_funcs;
std::string decls_basename = exe_stem + "_decls.h";
int shard_index = 0;
int shard_lines = 0;
int total_shard_lines = 0;
int shards_written = 0;
std::stringstream shard_buf;
auto flush_shard = [&]() {
if (shard_buf.tellp() == std::streampos(0)) return;
std::filesystem::path shard_filename =
out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, shard_index);
std::string body = fmt::format(
"/* Generated by PSXRecomp - full.c shard {}. DO NOT EDIT. */\n"
"#include \"{}\"\n\n{}",
shard_index, decls_basename, shard_buf.str());
if (write_file_if_changed(shard_filename, body)) {
fmt::print("✓ Saved shard {} ({} lines) to {}\n",
shard_index, shard_lines, shard_filename.string());
++shards_written;
} else {
fmt::print("✓ Shard {} unchanged ({} lines)\n", shard_index, shard_lines);
}
total_shard_lines += shard_lines;
shard_index++;
shard_lines = 0;
shard_buf.str(std::string());
shard_buf.clear();
};
for (const auto& gf : gen_funcs_for_shards) {
if (shard_lines > 0 && shard_lines + gf.line_count > SHARD_LINE_BUDGET) {
flush_shard();
}
shard_buf << gf.full_code << "\n";
shard_lines += gf.line_count;
}
flush_shard();
// 3. Drop orphan shards / monolith after emit (shrink must not leave stale TUs).
{
std::error_code rm_ec;
std::filesystem::remove(output_filename, rm_ec);
for (int stale = shard_index; stale < 256; stale++) {
std::filesystem::path stale_shard =
out_dir / fmt::format("{}_full_{:02d}.c", exe_stem, stale);
std::filesystem::remove(stale_shard, rm_ec);
}
}
fmt::print("✓ Wrote {} shards ({} total lines, {} updated) for {}\n\n",
shard_index, total_shard_lines, shards_written, exe_stem);
}
// Per-function code-range manifest (design §8): consumed by the overlay
// loader's per-entry validity hash. Emitted alongside _full.c for every
// build; only the overlay path actually loads it.
const std::string& ranges_manifest = all_ranges_manifest;
{
std::filesystem::path ranges_filename = out_dir / (exe_stem + "_full.ranges");
if (write_file_if_changed(ranges_filename, ranges_manifest)) {
fmt::print("✓ Saved code-range manifest to {}\n\n",
ranges_filename.string());
} else {
fmt::print("✓ Code-range manifest unchanged ({})\n\n",
ranges_filename.string());
}
}
// Generate dispatch table (tomba_dispatch.c)
// This maps PS1 addresses to compiled C functions so call_by_address() can
// dispatch dynamic jalr/jr calls to the right compiled function.
{
std::filesystem::path dispatch_filename = out_dir / (exe_stem + "_dispatch.c");
fmt::print("Generating dispatch table: {}\n", dispatch_filename.string());
std::string dispatch_source, dispatch_error;
if (!PSXRecomp::emit_game_dispatch(dispatch_units, *exe,
ranges_manifest, dispatch_source,
dispatch_error)) {
fmt::print(stderr, "ERROR: {}\n", dispatch_error);
return 1;
}
// Entries of the home compile, plus those of its segment variants.
size_t variant_entries = 0;
for (size_t u = 1; u < dispatch_units.size(); ++u)
variant_entries += dispatch_units[u].dispatch_addrs.size();
const std::string variant_note = variant_entries
? fmt::format(", plus {} in segment variants", variant_entries) : std::string();
if (write_file_if_changed(dispatch_filename, dispatch_source)) {
fmt::print("✓ Dispatch table written ({} entries{})\n\n",
dispatch_addrs.size(), variant_note);
} else {
fmt::print("✓ Dispatch table unchanged ({} entries{})\n\n",
dispatch_addrs.size(), variant_note);
}
}
return 0;
}