From dd5fdec6bbd036b8c208f7029ae74717e08ca2d6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:36:48 -0700 Subject: [PATCH 1/2] 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 --- docs/ENHANCEMENTS.md | 14 + docs/MOD_PACKAGES.md | 7 + docs/config_schema.md | 48 +++ recompiler/CMakeLists.txt | 55 +++ recompiler/include/code_generator.h | 8 + recompiler/src/code_generator.cpp | 47 +++ recompiler/src/config_loader.cpp | 66 +++ recompiler/src/config_loader.h | 20 + recompiler/src/main_psx.cpp | 5 + recompiler/src/segment_variants.cpp | 3 +- .../tests/draw_distance_codegen_test.cpp | 376 ++++++++++++++++++ runtime/include/draw_distance.h | 71 ++++ runtime/include/mod_plugins.h | 11 + runtime/runtime.cmake | 1 + runtime/src/dirty_ram_interp.c | 17 + runtime/src/draw_distance.c | 74 ++++ runtime/src/main.cpp | 13 + runtime/tests/test_draw_distance_interp.c | 322 +++++++++++++++ .../tests/test_mod_owned_display_controls.py | 1 + 19 files changed, 1158 insertions(+), 1 deletion(-) create mode 100644 recompiler/tests/draw_distance_codegen_test.cpp create mode 100644 runtime/include/draw_distance.h create mode 100644 runtime/src/draw_distance.c create mode 100644 runtime/tests/test_draw_distance_interp.c diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index b978dbbd..9723ea45 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -979,3 +979,17 @@ surface, including copies between the buffers and 1000-column copies staged in chunks; at 18x both buffers must read back at S with two tiles. In R4 on an M4, the 8K preset reports `effective_scale 18, internal_lines 4320, hr_scale 1, hires_fbo 5760x9216`, and `screenshot_hires` in a race is 5760×4320 with the rear-view mirror present. + +## DD1 — Opt-in draw-distance clamps (2026-10-01) + +`[[draw_distance.clamp]]` (docs/config_schema.md) lists a title's +ordering-table range guards. While a trusted mod has called +`psx_mod_set_draw_distance_clamp(1)`, the guard's depth index is clamped to +the last safe slot before the guard runs, so far geometry the game would drop +stays in the farthest bucket (drawn first, under everything nearer) instead +of popping in later. Raising the limit instead can push a biased primitive +past the end of the table. Off by default and at every session start; +identity when off; main executable only (overlay code keeps its own code); +native code and the dirty-RAM interpreter agree +(`draw_distance_codegen_test`, `draw_distance_interp_test`). First user: R4's +course renderers (RidgeRacerType4Recomp, Max Detail). diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index fdae2c93..4bb8d195 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -548,6 +548,7 @@ defensive, for the state listed here. | World-scene predicate | `psx_mod_set_world_scene_predicate` | NULL | every session | | Retained-scene predicate | `psx_mod_set_retained_scene_predicate` | NULL | every session | | Adaptive backdrop preload | `psx_mod_set_adaptive_backdrop_preload` | 0 | every session | +| Draw-distance clamps | `psx_mod_set_draw_distance_clamp` | off | every session | | Bezel artwork | `psx_mod_set_bezel_artwork` | none (see below) | every session | | Frame-interpolation blend mode | `psx_mod_set_frame_interpolation_blend` | default | every session | | Native VBlank pacing and its rate | `psx_mod_set_native_vblank_rate` | off, 0 | every session | @@ -621,6 +622,12 @@ selects uncapped host speed. A zero-frame release stops acceleration as soon as the sustained-load predicate clears, which is appropriate for timing-sensitive or speedrun-oriented packages. +`psx_mod_set_draw_distance_clamp(enabled)` switches the title's +`[[draw_distance.clamp]]` sites (docs/config_schema.md) on for the session: a +far primitive the game would drop past the end of its ordering table is kept +in the farthest slot. It returns 0 when the title lists no sites. More +primitives mean more guest work, so it belongs to an opt-in feature. + `psx_mod_set_disc_speed(divisor, instant_max_per_frame)` is the guest-visible alternative. Divisors 2 and 4 shorten emulated CD deadlines; zero selects the bounded instant scheduler. Because this changes interrupt timing, packages diff --git a/docs/config_schema.md b/docs/config_schema.md index 34eb4d24..e7211d1b 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -56,6 +56,7 @@ How the two configs relate: [prepare_disc] # optional; data-track digests for prepare/verify [netplay] # optional; TOC / cue policy for online [recompiler] +[draw_distance] # optional; opt-in far-geometry clamps (below) [runtime] [audit] ``` @@ -372,6 +373,53 @@ vertical scan X bounds, and any associated respawn-reset interval together. Keep authored placement flags and vertical bounds intact. Empty is inert; configured sites require regeneration. +## Draw-distance clamps (`[[draw_distance.clamp]]`) + +Many PS1 renderers drop a primitive whose depth index falls past the end of +their ordering table (`sltiu t, z, N; beqz t, reject`). The limit protects the +OT, so raising it can write past the table. A title can list such guards and +let a trusted mod keep the far primitive in the farthest slot instead: + +```toml +[[draw_distance.clamp]] +address = "0x80061230" +expected = "0x2C4A01C0" # sltiu t2, v0, 0x1C0 +reg = 2 # v0: the depth index the guard tests +max = 0x1BF # last safe index + +[[draw_distance.clamp]] +address = "0x80066FF8" +expected = "0x2441FFFF" # addiu at, v0, -1 (then sltiu at, at, 0x1BF) +reg = 2 +max = 0x1BF +``` + +- While a mod has called `psx_mod_set_draw_distance_clamp(1)`, `reg` is + clamped to `max` (signed: `if ((int32_t)reg > max) reg = max`) immediately + before the instruction at `address` runs. The original guard then keeps + the primitive and the code that follows indexes the table with the clamped + value. A negative (wrapped) value is left alone, so a guard that also + rejects too-near primitives keeps doing so. +- Off, the default and the state at every session start, the sites run the + original code. Netplay never activates a mod, so it stays vanilla. +- `expected` is the complete instruction word and must be an ALU + instruction (`ADDI`/`ADDIU`/`SLTI`/`SLTIU`/`ANDI`/`ORI`/`XORI`, or a + SPECIAL shift/arithmetic/logic/`SLT`/`SLTU`) that reads `reg`. `reg` is + 1..31; `max` is a signed 32-bit integer. Addresses are unique by physical + address. +- Main executable only. Generation fails when the listed word is not + `expected`, or when the previous instruction loads `reg` (its value would + still be in the load-delay slot). Captured overlay code at a listed address + keeps its own code, so the sites do not enter the overlay-cache identity. + The dirty-RAM interpreter applies a clamp where the game's text image holds + the listed word. +- Pick `max` so the clamped index reaches no further than the farthest index + the unclamped code can already produce. Choose the register the following + code really indexes with: in the `addiu` form above, the guard tests `at` + but the slot is computed from `v0`. +- Regenerate after changing the list. With no sites the generated code is + unchanged. + ## Runtime block Consumed by the cmake macro `psxrecomp_v4_add_runtime_target` (eventually) diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 56e8c7aa..1c670815 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -531,6 +531,34 @@ if(BUILD_TESTING) add_test(NAME ws_cull_edge_codegen_test COMMAND ws_cull_edge_codegen_test) + # [[draw_distance.clamp]]: config rules, the emitted main-EXE clamp and + # its guards, the shared declaration and the unchanged overlay identity. + add_executable(draw_distance_codegen_test + tests/draw_distance_codegen_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(draw_distance_codegen_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(draw_distance_codegen_test PRIVATE fmt rabbitizer) + add_test(NAME draw_distance_codegen_test + COMMAND draw_distance_codegen_test) + # The same explicit cull kinds in the dirty-RAM interpreter: gpu.c's # sorted, capped site store and real interpreted bgez/bltz/branch-keep/ # clip-edge instructions at margin 0 and at a forced margin. The test @@ -560,6 +588,33 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_BINARY_DIR}/ws_cull_edge_interp_stderr.txt) endif() + # [[draw_distance.clamp]] in the dirty-RAM interpreter: draw_distance.c's + # store and switch and real interpreted guard instructions. The test + # #includes draw_distance.c and dirty_ram_interp.c (as above). + if(NOT MSVC) + add_executable(draw_distance_interp_test + ../runtime/tests/test_draw_distance_interp.c + ../runtime/src/ws_aspect_cone_math.c) + target_include_directories(draw_distance_interp_test PRIVATE + ../runtime/include + ../runtime/src) + target_compile_options(draw_distance_interp_test PRIVATE + -O2 -flto -ffunction-sections -fdata-sections) + if(APPLE) + target_link_options(draw_distance_interp_test PRIVATE + -flto -Wl,-dead_strip) + else() + target_link_options(draw_distance_interp_test PRIVATE + -flto -Wl,--gc-sections) + endif() + if(NOT WIN32) + target_link_libraries(draw_distance_interp_test PRIVATE m) + endif() + add_test(NAME draw_distance_interp_test + COMMAND draw_distance_interp_test + ${CMAKE_CURRENT_BINARY_DIR}/draw_distance_interp_stderr.txt) + endif() + # Uncached (KSEG1) code charges a fetch before every instruction; cached # code keeps the line-leader rule (test_uncached_fetch_charge.py checks # the BIOS emitter on OpenBIOS and the cycle model). diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 64bd2d56..c58ada47 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -177,6 +177,11 @@ struct CodeGenConfig { // scales tan(angle) by the current horizontal reveal factor. std::vector ws_cull_angle_sites; + // [[draw_distance.clamp]]: main-EXE ordering-table range guards. While + // g_psx_draw_distance_clamp is set the site's register is clamped to its + // max before the instruction runs; overlay code keeps its own code. + std::vector draw_distance_clamp_sites; + // Exact model-participation cosine compares that gain a camera-horizontal // aspect envelope while preserving the vanilla vertical cone. PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; @@ -481,6 +486,9 @@ private: // Instruction translation std::string translate_instruction(uint32_t addr, uint32_t instr); + std::string translate_instruction_core(uint32_t addr, uint32_t instr); + // The [[draw_distance.clamp]] statement for (addr, instr), or "". + std::string draw_distance_clamp_prefix(uint32_t addr, uint32_t instr) const; // Register name mapping static std::string reg_name(int reg_num); diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 5ef7719d..3e64db5b 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -870,7 +870,52 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } +// [[draw_distance.clamp]]: a main-EXE ordering-table range guard. While a +// mod has the clamps on, the site's register is clamped to `max` before the +// instruction runs, so the guard keeps a far primitive in the last OT slot +// instead of dropping it. Identity while off. Captured overlays may hold +// unrelated code at a listed address and keep their own code. +std::string CodeGenerator::draw_distance_clamp_prefix(uint32_t addr, + uint32_t instr) const { + if (config_.overlay_mode || config_.draw_distance_clamp_sites.empty()) + return {}; + for (const auto& site : config_.draw_distance_clamp_sites) { + if ((site.address & 0x1FFFFFFFu) != (addr & 0x1FFFFFFFu)) continue; + if (instr != site.expected) { + fmt::print(stderr, + "ERROR: [[draw_distance.clamp]] expected 0x{:08X} at " + "0x{:08X}, found 0x{:08X}\n", + site.expected, addr, instr); + std::exit(1); + } + // A load into the register in the previous slot is still in flight + // (MIPS-I load delay): the clamp would act on the stale value. + if (const auto prev = exe_.read_word(addr - 4u)) { + const uint32_t pop = *prev >> 26; + if (pop >= 0x20u && pop <= 0x26u && get_rt(*prev) == site.reg) { + fmt::print(stderr, + "ERROR: [[draw_distance.clamp]] site 0x{:08X} is " + "the load-delay slot of a load into reg {}\n", + addr, site.reg); + std::exit(1); + } + } + return fmt::format( + "if (g_psx_draw_distance_clamp && (int32_t){0} > {1}) {0} = " + "(uint32_t)({1}); /* draw-distance clamp: keep far geometry */", + reg_name((int)site.reg), site.max); + } + return {}; +} + std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { + std::string clamp = draw_distance_clamp_prefix(addr, instr); + if (clamp.empty()) return translate_instruction_core(addr, instr); + // The core translation carries its own indent; give the clamp the same. + return config_.indent + clamp + "\n" + translate_instruction_core(addr, instr); +} + +std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t instr) { uint32_t opcode = (instr >> 26) & 0x3F; uint32_t funct = instr & 0x3F; @@ -3471,6 +3516,8 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern int psx_game_option_store(uint32_t addr, int val); /* persisted OPTION restore-at-init (game_options.c) */\n"; ss << "extern uint32_t psx_ws_backdrop_value(uint32_t orig, int is_end, int window_cols); /* ws backdrop preload (gpu.c) */\n"; ss << "extern void gte_ws_set_suppress(int on); /* widescreen far-backdrop un-squash (gte.cpp) */\n"; + if (!config_.overlay_mode && !config_.draw_distance_clamp_sites.empty()) + ss << "extern uint32_t g_psx_draw_distance_clamp; /* [[draw_distance.clamp]] switch (draw_distance.c) */\n"; ss << "extern uint32_t g_debug_last_store_pc; /* exact PC of the executing SW/SH/SB — wtrace/readtrace producer attribution (debug_server.c) */\n\n"; } diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index ab673327..bc962887 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -887,6 +887,70 @@ fs::path find_project_root(const fs::path& config_path) { return fallback; } +bool draw_distance_clamp_reads(uint32_t instr, uint32_t reg) { + if (reg == 0u || reg > 31u) return false; + const uint32_t op = instr >> 26; + const uint32_t rs = (instr >> 21) & 31u; + const uint32_t rt = (instr >> 16) & 31u; + // ADDI, ADDIU, SLTI, SLTIU, ANDI, ORI, XORI read rs (LUI reads nothing). + if (op >= 0x08u && op <= 0x0Eu) return rs == reg; + if (op != 0x00u) return false; + const uint32_t funct = instr & 0x3Fu; + // SLL, SRL, SRA: rt. SLLV, SRLV, SRAV: rt and rs. + if (funct == 0x00u || funct == 0x02u || funct == 0x03u) return rt == reg; + if (funct == 0x04u || funct == 0x06u || funct == 0x07u) + return rs == reg || rt == reg; + // ADD, ADDU, SUB, SUBU, AND, OR, XOR, NOR, SLT, SLTU. + if (funct >= 0x20u && funct <= 0x2Bu && funct != 0x28u && funct != 0x29u) + return rs == reg || rt == reg; + return false; +} + +// [[draw_distance.clamp]] (docs/config_schema.md "Draw-distance clamps"). +static std::vector parse_draw_distance_clamps( + const toml::value& cfg, const fs::path& config_path) { + std::vector sites; + if (!cfg.contains("draw_distance")) return sites; + const toml::value& dd = toml::find(cfg, "draw_distance"); + if (!dd.contains("clamp")) return sites; + const auto& items = toml::find(dd, "clamp"); + std::set seen; + for (const auto& item : items) { + DrawDistanceClampSite site; + site.address = parse_hex(toml::find(item, "address"), + "draw_distance.clamp.address"); + site.expected = parse_hex(toml::find(item, "expected"), + "draw_distance.clamp.expected"); + const int64_t reg = toml::find(item, "reg"); + const int64_t max = toml::find(item, "max"); + if ((site.address & 3u) != 0u) + throw std::runtime_error(fmt::format( + "{}: draw-distance clamp address 0x{:08X} is not " + "instruction-aligned", config_path.string(), site.address)); + if (!seen.insert(site.address & 0x1FFFFFFFu).second) + throw std::runtime_error(fmt::format( + "{}: duplicate draw-distance clamp address 0x{:08X}", + config_path.string(), site.address)); + if (reg < 1 || reg > 31) + throw std::runtime_error(fmt::format( + "{}: draw-distance clamp at 0x{:08X}: reg must be 1..31", + config_path.string(), site.address)); + if (max < INT32_MIN || max > INT32_MAX) + throw std::runtime_error(fmt::format( + "{}: draw-distance clamp at 0x{:08X}: max is not a signed " + "32-bit value", config_path.string(), site.address)); + site.reg = (uint32_t)reg; + site.max = (int32_t)max; + if (!draw_distance_clamp_reads(site.expected, site.reg)) + throw std::runtime_error(fmt::format( + "{}: draw-distance clamp at 0x{:08X}: expected 0x{:08X} must " + "be an ALU instruction that reads reg {}", + config_path.string(), site.address, site.expected, site.reg)); + sites.push_back(site); + } + return sites; +} + // Derive the output filename stem from a rom basename. Mirrors the Python // audit_config.py logic: strip a trailing .BIN/.EXE (case-insensitive) but // preserve dotted names like "SCUS_942.36" unchanged. @@ -2306,6 +2370,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { loaded.ws_cull_clip_edge_x_load_sites = std::move(ws_cull_clip_edge_x_load_sites); loaded.ws_cull_clip_edge_width = ws_cull_clip_edge_width; + loaded.draw_distance_clamp_sites = + parse_draw_distance_clamps(cfg, config_path); return loaded; } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 089a4f02..87f66ffe 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -108,6 +108,23 @@ struct WidescreenAngleSite { uint32_t expected = 0; // guarded ADDI/ADDIU with rs == zero }; +// [[draw_distance.clamp]]: an ordering-table range guard that a trusted mod +// may turn from "drop the far primitive" into "keep it in the last slot". +// While the runtime switch is on (psx_mod_set_draw_distance_clamp), `reg` is +// clamped to `max` (signed) immediately before the instruction at `address` +// runs. Main EXE only; the complete instruction word is the guard. +struct DrawDistanceClampSite { + uint32_t address = 0; + uint32_t expected = 0; // the instruction; `reg` must be one of its sources + uint32_t reg = 0; // 1..31 + int32_t max = 0; +}; + +// True when `instr` is an ALU instruction (I-type or SPECIAL R-type, not a +// load, store, branch or jump) that reads GPR `reg`. These are the only +// instructions a draw-distance clamp may precede. +bool draw_distance_clamp_reads(uint32_t instr, uint32_t reg); + // Aspect-aware horizontal participation cone. The exact compare sites are // full-word guarded because overlay variants can reuse a virtual address for // unrelated code. Registers are MIPS GPR indices captured at each comparison. @@ -1217,6 +1234,9 @@ struct GameConfig { // [widescreen.cull] clip_edge_width -- the screen width a right-edge clip // bound equals. 0 = the first screen_w_imms entry (0x140 by default). uint32_t ws_cull_clip_edge_width = 0; + // [[draw_distance.clamp]] -- opt-in "keep far geometry" clamps. Empty by + // default; inert until a mod switches them on; regen required. + std::vector draw_distance_clamp_sites; }; // Effective clip_edge_width: explicit value, else screen_w_imms[0], else 320. diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 83376f11..6f865da2 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -242,6 +242,7 @@ static int psxrecomp_game_main(int argc, char** argv) { uint32_t ws_cull_clip_edge_width = 0; // [widescreen.cull] clip_edge_width std::vector ws_cull_keep; std::vector ws_cull_angle; + std::vector draw_distance_clamps; // [[draw_distance.clamp]] PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; int ws_cull_activation_guard_pixels = 0; std::vector ws_cull_w_imms = { 0x140, 0x141 }; // [widescreen.cull] screen_w_imms @@ -316,6 +317,7 @@ static int psxrecomp_game_main(int argc, char** argv) { 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; @@ -635,6 +637,8 @@ static int psxrecomp_game_main(int argc, char** argv) { 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); @@ -1486,6 +1490,7 @@ static int psxrecomp_game_main(int argc, char** argv) { 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; diff --git a/recompiler/src/segment_variants.cpp b/recompiler/src/segment_variants.cpp index 99255948..39a7ed0c 100644 --- a/recompiler/src/segment_variants.cpp +++ b/recompiler/src/segment_variants.cpp @@ -190,7 +190,8 @@ CodeGenConfig move_exact_config_sites(const CodeGenConfig& cfg, At at) { v.ws_bg2d_bufbase_site = at(v.ws_bg2d_bufbase_site); v.ws_bg2d_cap_site = at(v.ws_bg2d_cap_site); // Physically matched, kept as written: ws_signed_x_bound_sites, - // ws_cull_keep_sites, ws_cull_angle_sites, ws_aspect_cone.sites. + // ws_cull_keep_sites, ws_cull_angle_sites, ws_aspect_cone.sites, + // draw_distance_clamp_sites. return v; } diff --git a/recompiler/tests/draw_distance_codegen_test.cpp b/recompiler/tests/draw_distance_codegen_test.cpp new file mode 100644 index 00000000..666c1346 --- /dev/null +++ b/recompiler/tests/draw_distance_codegen_test.cpp @@ -0,0 +1,376 @@ +// [[draw_distance.clamp]]: config parsing and validation, the emitted +// main-EXE clamp (and its absence everywhere else), the main-EXE guards, the +// shared declaration, and the overlay-cache identity (unchanged: overlays +// keep their own code at a listed address). +#include "code_generator.h" +#include "config_loader.h" +#include "control_flow.h" +#include "../../runtime/include/draw_distance.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#include +#endif + +namespace fs = std::filesystem; + +namespace { + +constexpr uint32_t kBase = 0x80010000u; +int failures = 0; + +void check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + ++failures; + } +} + +void append_word(std::vector& bytes, uint32_t word) { + bytes.push_back(static_cast(word)); + bytes.push_back(static_cast(word >> 8)); + bytes.push_back(static_cast(word >> 16)); + bytes.push_back(static_cast(word >> 24)); +} + +fs::path write_temp_config(const char* stem, const std::string& body) { + const auto nonce = std::chrono::high_resolution_clock::now() + .time_since_epoch().count(); + fs::path path = fs::temp_directory_path() / + (std::string(stem) + "-" + std::to_string(nonce) + ".toml"); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << body; + return path; +} + +std::string base_config() { + return R"toml([game] +name = "Draw Distance Test" +id = "TEST-00000" +exe = "TEST.EXE" +load_address = "0x80010000" +entry_pc = "0x80010000" +text_size = "0x1000" +stack_base = "0x801FFFF0" + +[recompiler] +seeds = "seeds.txt" +out_dir = "generated" +)toml"; +} + +std::string clamp(const char* address, const char* expected, int reg, + const char* max) { + return std::string("\n[[draw_distance.clamp]]\naddress = \"") + address + + "\"\nexpected = \"" + expected + "\"\nreg = " + std::to_string(reg) + + "\nmax = " + max + "\n"; +} + +PSXRecompV4::GameConfig load(const std::string& body) { + fs::path path = write_temp_config("draw-distance", body); + auto config = PSXRecompV4::load_game_config(path); + fs::remove(path); + return config; +} + +bool load_throws_with(const std::string& body, const char* needle) { + fs::path path = write_temp_config("draw-distance-bad", body); + bool matched = false; + try { + (void)PSXRecompV4::load_game_config(path); + } catch (const std::exception& e) { + matched = std::string(e.what()).find(needle) != std::string::npos; + if (!matched) std::fprintf(stderr, " (threw: %s)\n", e.what()); + } + fs::remove(path); + return matched; +} + +// A function made of `words` followed by `jr ra ; nop`. +PSXRecomp::GeneratedFunction generate(std::initializer_list words, + const PSXRecomp::CodeGenConfig& config) { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + exe.header.initial_pc = kBase; + for (uint32_t w : words) append_word(exe.code_data, w); + append_word(exe.code_data, 0x03E00008u); + append_word(exe.code_data, 0x00000000u); + const uint32_t size = static_cast(exe.code_data.size()); + exe.header.file_size = size; + + PSXRecomp::Function function{}; + function.start_addr = kBase; + function.end_addr = kBase + size; + function.size = size; + function.name = "clamp_site_test"; + + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, cfg); +} + +// Runs `body` in a child and reports whether it exited with a failure status +// (the main-EXE guards call std::exit(1)). Windows has no fork: the Nth call +// re-runs this binary as `--exit-probe N`, which runs only the Nth body. +const char* g_self = nullptr; +int g_exit_probe = -1; +int g_probe_seq = 0; + +template +bool exits_with_failure(F&& body) { +#if defined(_WIN32) + const int seq = g_probe_seq++; + if (g_exit_probe >= 0) { + if (seq != g_exit_probe) return false; + body(); + std::fflush(nullptr); + std::_Exit(0); + } + std::fflush(nullptr); + const std::string quoted = std::string("\"") + g_self + "\""; + const std::string n = std::to_string(seq); + const intptr_t rc = _spawnl(_P_WAIT, g_self, quoted.c_str(), "--exit-probe", + n.c_str(), nullptr); + return rc > 0; +#else + (void)g_probe_seq; + std::fflush(nullptr); + const pid_t pid = fork(); + if (pid == 0) { + if (!std::freopen("/dev/null", "w", stderr)) _exit(3); + body(); + _exit(0); + } + int status = 0; + if (pid < 0 || waitpid(pid, &status, 0) != pid) return false; + return WIFEXITED(status) && WEXITSTATUS(status) != 0; +#endif +} + +constexpr uint32_t kSltiuT2V0 = 0x2C4A01C0u; // sltiu t2, v0, 0x1C0 +constexpr uint32_t kAddiuAtV0 = 0x2441FFFFu; // addiu at, v0, -1 +constexpr uint32_t kSravV0 = 0x00561007u; // srav v0, s6, v0 +constexpr uint32_t kLwV0 = 0x8C820060u; // lw v0, 0x60(a0) +constexpr uint32_t kLwV1 = 0x8C830060u; // lw v1, 0x60(a0) +constexpr uint32_t kBeqZero = 0x10000002u; // b +2 (beq zero, zero) +constexpr uint32_t kNop = 0x00000000u; + +const char* kClampV0 = + "if (g_psx_draw_distance_clamp && (int32_t)cpu->gpr[2] > 447) " + "cpu->gpr[2] = (uint32_t)(447);"; + +PSXRecomp::CodeGenConfig with_site(uint32_t address, uint32_t expected, + uint32_t reg, int32_t max) { + PSXRecomp::CodeGenConfig config{}; + PSXRecompV4::DrawDistanceClampSite site; + site.address = address; + site.expected = expected; + site.reg = reg; + site.max = max; + config.draw_distance_clamp_sites.push_back(site); + return config; +} + +void reads_classifier() { + using PSXRecompV4::draw_distance_clamp_reads; + check(draw_distance_clamp_reads(kSltiuT2V0, 2), "sltiu reads rs"); + check(!draw_distance_clamp_reads(kSltiuT2V0, 10), "sltiu does not read rt"); + check(draw_distance_clamp_reads(kAddiuAtV0, 2), "addiu reads rs"); + check(draw_distance_clamp_reads(kSravV0, 2) && + draw_distance_clamp_reads(kSravV0, 22), + "srav reads rs and rt"); + check(draw_distance_clamp_reads(0x00021080u, 2), "sll reads rt"); // sll v0,v0,2 + check(draw_distance_clamp_reads(0x0062102Bu, 3), "sltu reads rs"); // sltu v0,v1,v0 + check(!draw_distance_clamp_reads(0x3C02800Bu, 2), "lui reads nothing"); + check(!draw_distance_clamp_reads(kLwV0, 4), "a load is not a clamp site"); + check(!draw_distance_clamp_reads(0x10400002u, 2), "a branch is not a clamp site"); + check(!draw_distance_clamp_reads(0x00430018u, 2), "mult is not a clamp site"); + check(!draw_distance_clamp_reads(0x03E00008u, 31), "jr is not a clamp site"); + check(!draw_distance_clamp_reads(kSltiuT2V0, 0), "reg 0 is refused"); +} + +void loader() { + auto none = load(base_config()); + check(none.draw_distance_clamp_sites.empty(), "no clamps by default"); + + auto two = load(base_config() + + clamp("0x80061230", "0x2C4A01C0", 2, "0x1BF") + + clamp("0x80066FF8", "0x2441FFFF", 2, "447")); + check(two.draw_distance_clamp_sites.size() == 2, "two clamps parsed"); + if (two.draw_distance_clamp_sites.size() == 2) { + const auto& a = two.draw_distance_clamp_sites[0]; + const auto& b = two.draw_distance_clamp_sites[1]; + check(a.address == 0x80061230u && a.expected == kSltiuT2V0 && + a.reg == 2u && a.max == 0x1BF, + "first clamp fields"); + check(b.address == 0x80066FF8u && b.expected == kAddiuAtV0 && + b.max == 447, + "second clamp fields (decimal max)"); + } + auto neg = load(base_config() + clamp("0x80061230", "0x2C4A01C0", 2, "-5")); + check(neg.draw_distance_clamp_sites.size() == 1 && + neg.draw_distance_clamp_sites[0].max == -5, + "a negative max is accepted"); + + check(load_throws_with(base_config() + clamp("0x80061232", "0x2C4A01C0", 2, "1"), + "not instruction-aligned"), + "unaligned address refused"); + check(load_throws_with(base_config() + + clamp("0x80061230", "0x2C4A01C0", 2, "1") + + clamp("0xA0061230", "0x2C4A01C0", 2, "1"), + "duplicate draw-distance clamp"), + "duplicate physical address refused"); + check(load_throws_with(base_config() + clamp("0x80061230", "0x2C4A01C0", 0, "1"), + "reg must be 1..31"), + "reg 0 refused"); + check(load_throws_with(base_config() + clamp("0x80061230", "0x2C4A01C0", 32, "1"), + "reg must be 1..31"), + "reg 32 refused"); + check(load_throws_with(base_config() + clamp("0x80061230", "0x2C4A01C0", 3, "1"), + "must be an ALU instruction that reads reg 3"), + "a reg the instruction does not read is refused"); + check(load_throws_with(base_config() + clamp("0x80061230", "0x8C820060", 4, "1"), + "must be an ALU instruction"), + "a load site is refused"); + check(load_throws_with(base_config() + + clamp("0x80061230", "0x2C4A01C0", 2, "0x100000000"), + "not a signed 32-bit value"), + "max outside int32 refused"); +} + +void emission() { + // The clamp runs immediately before the guard it protects. + const auto cfg = with_site(kBase + 4u, kSltiuT2V0, 2, 447); + const auto out = generate({kSravV0, kSltiuT2V0, kNop}, cfg).full_code; + const size_t at = out.find(kClampV0); + const size_t guard = out.find("0x80010004:"); + check(at != std::string::npos, "main-EXE site emits the clamp"); + check(at != std::string::npos && guard != std::string::npos && at < guard, + "the clamp precedes the guard instruction"); + check(at != std::string::npos && + out.find(kClampV0, at + 1) == std::string::npos, + "exactly one clamp emitted"); + check(at != std::string::npos && at >= 5 && + out.compare(at - 5, 5, "\n ") == 0 && + out.find("\n cpu->gpr[10] =", at) != std::string::npos, + "the clamp and the guard share the statement indent"); + + // The addiu form clamps the source the slot index is computed from. + const auto addiu = generate({kSravV0, kAddiuAtV0, kNop}, + with_site(kBase + 4u, kAddiuAtV0, 2, 447)).full_code; + check(addiu.find(kClampV0) != std::string::npos, + "addiu site emits the clamp on its source"); + + // A site in a branch delay slot is clamped inside the slot. + const auto ds = generate({kBeqZero, kSltiuT2V0, kNop, kNop}, + with_site(kBase + 4u, kSltiuT2V0, 2, 447)).full_code; + check(ds.find(kClampV0) != std::string::npos, "delay-slot site is clamped"); + + // No sites: nothing about the clamp anywhere. + PSXRecomp::CodeGenConfig plain{}; + const auto vanilla = generate({kSravV0, kSltiuT2V0, kNop}, plain).full_code; + check(vanilla.find("draw_distance") == std::string::npos && + vanilla.find("draw-distance") == std::string::npos, + "no sites: vanilla code"); + + // A site elsewhere leaves this instruction alone. + const auto other = generate({kSravV0, kSltiuT2V0, kNop}, + with_site(kBase + 0x100u, kSltiuT2V0, 2, 447)).full_code; + check(other.find("draw-distance") == std::string::npos, + "unlisted address: vanilla code"); + + // Captured overlays keep their own code at a listed address, matching + // word or not. + auto overlay = with_site(kBase + 4u, kSltiuT2V0, 2, 447); + overlay.overlay_mode = true; + check(generate({kSravV0, kSltiuT2V0, kNop}, overlay).full_code.find( + "draw-distance") == std::string::npos, + "overlay variant with the listed word stays vanilla"); + check(!exits_with_failure([&] { generate({kSravV0, kAddiuAtV0, kNop}, overlay); }), + "overlay variant with another word generates"); +} + +void guards() { + const auto cfg = with_site(kBase + 4u, kSltiuT2V0, 2, 447); + check(!exits_with_failure([&] { generate({kSravV0, kSltiuT2V0, kNop}, cfg); }), + "matching word generates"); + check(exits_with_failure([&] { generate({kSravV0, kAddiuAtV0, kNop}, cfg); }), + "a main-EXE word mismatch fails generation"); + check(exits_with_failure([&] { generate({kLwV0, kSltiuT2V0, kNop}, cfg); }), + "a site in the load-delay slot of its register fails generation"); + check(!exits_with_failure([&] { generate({kLwV1, kSltiuT2V0, kNop}, cfg); }), + "a load into another register before the site is fine"); +} + +void shared_decls() { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + std::vector functions; + { + PSXRecomp::CodeGenerator plain(exe); + check(plain.build_shared_decls_header(functions).find( + "g_psx_draw_distance_clamp") == std::string::npos, + "no sites: the declarations do not change"); + } + { + const auto cfg = with_site(kBase + 4u, kSltiuT2V0, 2, 447); + PSXRecomp::CodeGenerator gen(exe, cfg); + check(gen.build_shared_decls_header(functions).find( + "extern uint32_t g_psx_draw_distance_clamp;") != std::string::npos, + "sites: the switch is declared"); + } +} + +void overlay_identity_unchanged() { + auto none = load(base_config()); + auto some = load(base_config() + clamp("0x80061230", "0x2C4A01C0", 2, "0x1BF")); + check(PSXRecompV4::overlay_codegen_config_hash(none) == + PSXRecompV4::overlay_codegen_config_hash(some), + "clamps do not enter the overlay-cache identity"); +} + +void runtime_value() { + check(psx_draw_distance_clamp_value(0x1C0u, 0x1BF) == 0x1BFu, "clamps past max"); + check(psx_draw_distance_clamp_value(0x7FFu, 0x1BF) == 0x1BFu, "clamps far past max"); + check(psx_draw_distance_clamp_value(0x1BFu, 0x1BF) == 0x1BFu, "max is kept"); + check(psx_draw_distance_clamp_value(5u, 0x1BF) == 5u, "near values unchanged"); + check(psx_draw_distance_clamp_value(0u, 0x1BF) == 0u, "zero unchanged"); + check(psx_draw_distance_clamp_value(0xFFFFFFFFu, 0x1BE) == 0xFFFFFFFFu, + "a negative value is left for the original predicate"); +} + +} // namespace + +int main(int argc, char** argv) { + g_self = argv[0]; + if (argc >= 3 && std::string(argv[1]) == "--exit-probe") + g_exit_probe = std::atoi(argv[2]); + reads_classifier(); + loader(); + emission(); + guards(); + shared_decls(); + overlay_identity_unchanged(); + runtime_value(); + if (failures) { + std::fprintf(stderr, "draw_distance_codegen_test: %d failure(s)\n", failures); + return 1; + } + std::printf("draw_distance_codegen_test: OK\n"); + return 0; +} diff --git a/runtime/include/draw_distance.h b/runtime/include/draw_distance.h new file mode 100644 index 00000000..6c2517c5 --- /dev/null +++ b/runtime/include/draw_distance.h @@ -0,0 +1,71 @@ +/* draw_distance.h - opt-in draw-distance clamps ([[draw_distance.clamp]]). + * + * Many PS1 renderers drop a primitive whose depth falls past the end of their + * ordering table: `sltiu t, z, N; beqz t, reject`. The limit protects the OT, + * so simply raising it can write past the table. A title lists such + * predicates in game.toml together with the register that carries the depth + * index and the last safe value: + * + * [[draw_distance.clamp]] + * address = "0x80061230" + * expected = "0x2C4A01C0" # sltiu t2, v0, 0x1C0 + * reg = 2 # v0 + * max = 0x1BF + * + * While a trusted mod has switched the clamps on, the listed register is + * clamped to `max` (signed) immediately before the listed instruction runs, + * so a far primitive is kept in the farthest OT slot instead of being + * dropped. Off (the default, reset at every session start) the sites run the + * original code. Main executable only: generated main-EXE code applies the + * clamp, captured overlay code at the same address keeps its own code, and + * the dirty-RAM interpreter applies it where the game's text image still + * holds the listed instruction. + * + * docs/config_schema.md "Draw-distance clamps" has the config rules. + */ +#ifndef PSX_DRAW_DISTANCE_H +#define PSX_DRAW_DISTANCE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct PSXDrawDistanceClampSite { + uint32_t address; /* any segment; stored physical */ + uint32_t expected; /* the complete instruction word at address */ + uint32_t reg; /* 1..31: a source register of `expected` */ + int32_t max; /* signed upper bound */ +} PSXDrawDistanceClampSite; + +#define PSX_DRAW_DISTANCE_CLAMP_SITES_MAX 256 + +/* Nonzero while the clamps are on. Generated main-EXE code reads it at every + * configured site. */ +extern uint32_t g_psx_draw_distance_clamp; +/* On and at least one site configured: the interpreter's per-instruction + * gate. */ +extern int g_psx_draw_distance_clamp_live; + +/* The title's sites (game config). Copied, sorted, capped at + * PSX_DRAW_DISTANCE_CLAMP_SITES_MAX (a longer list is logged). Returns the + * number kept. */ +int psx_draw_distance_set_clamp_sites(const PSXDrawDistanceClampSite *sites, + int count); +int psx_draw_distance_clamp_site_count(void); +/* The site at `pc` whose instruction is `insn`, or NULL. */ +const PSXDrawDistanceClampSite *psx_draw_distance_clamp_find(uint32_t pc, + uint32_t insn); + +/* The value the clamp leaves in the register. */ +static inline uint32_t psx_draw_distance_clamp_value(uint32_t value, + int32_t max) { + return (int32_t)value > max ? (uint32_t)max : value; +} + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_DRAW_DISTANCE_H */ diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2bb49d20..0be06aee 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -394,6 +394,17 @@ int psx_mod_set_load_acceleration(uint32_t wall_clock_multiplier, int psx_mod_set_disc_speed(uint32_t divisor, uint32_t instant_max_per_frame); +/* + * Turn the title's [[draw_distance.clamp]] sites on or off for this session + * (draw_distance.h): a far primitive the game would drop past the end of its + * ordering table is kept in the farthest slot instead. Returns 1 when the + * title configured sites, 0 when it has none (the call then changes nothing + * the guest sees). Off by default and reset to off at every session start; + * it adds guest work, so call it from activation, never in netplay. + */ +int psx_mod_set_draw_distance_clamp(int enabled); +int psx_mod_draw_distance_clamp_enabled(void); + /* Controller presentation values exposed to trusted game-owned plugins. */ enum { PSX_MOD_CONTROLLER_ANALOG = 1, diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5463e147..45f85a5e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -360,6 +360,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/debug_server.c ${PSXRECOMP_ROOT}/runtime/src/debug_trace_ranges.c ${PSXRECOMP_ROOT}/runtime/src/dirty_ram_interp.c + ${PSXRECOMP_ROOT}/runtime/src/draw_distance.c ${PSXRECOMP_ROOT}/runtime/src/game_dispatch_compat.c ${PSXRECOMP_ROOT}/runtime/src/psx_segment_miss.c ${PSXRECOMP_ROOT}/runtime/src/fntrace.c diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index b6edd193..d9262249 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -37,6 +37,7 @@ #include "starvation_ring.h" #include "fntrace.h" /* fntrace_is_game_started / fntrace_mark_game_started */ #include "psx_segment_miss.h" /* segment misses in static game code (§5.5) */ +#include "draw_distance.h" /* [[draw_distance.clamp]] sites */ #include #include @@ -1514,6 +1515,22 @@ static int exec_one_fetched(CPUState *cpu, uint32_t pc, uint32_t insn, (ld_rt != 0u); const uint32_t ld_before = is_ld ? cpu->gpr[ld_rt] : 0u; + /* [[draw_distance.clamp]] (draw_distance.h): while a mod has the clamps + * on, a listed main-EXE site clamps its register before it runs, exactly + * as the generated code does. Captured overlay code keeps its own code, + * so only the game's text image qualifies. One load when off. */ +#ifdef PSX_HAS_GAME_DISPATCH + if (g_psx_draw_distance_clamp_live) { + const PSXDrawDistanceClampSite *dd = + psx_draw_distance_clamp_find(pc, insn); + if (dd && psx_game_address_in_text(pc)) { + cpu->gpr[dd->reg] = + psx_draw_distance_clamp_value(cpu->gpr[dd->reg], dd->max); + cpu->gpr[0] = 0; + } + } +#endif + const int rv = exec_one_fetched_inner(cpu, pc, insn, next_pc_out); if (is_ld) { diff --git a/runtime/src/draw_distance.c b/runtime/src/draw_distance.c new file mode 100644 index 00000000..72ce5e7a --- /dev/null +++ b/runtime/src/draw_distance.c @@ -0,0 +1,74 @@ +/* draw_distance.c - the [[draw_distance.clamp]] site store and switch + * (draw_distance.h). */ +#include "draw_distance.h" +#include "mod_plugins.h" + +#include +#include + +uint32_t g_psx_draw_distance_clamp = 0; +int g_psx_draw_distance_clamp_live = 0; + +static PSXDrawDistanceClampSite s_sites[PSX_DRAW_DISTANCE_CLAMP_SITES_MAX]; +static int s_site_count = 0; + +static void update_live(void) { + g_psx_draw_distance_clamp_live = + (g_psx_draw_distance_clamp != 0u && s_site_count > 0) ? 1 : 0; +} + +static int site_cmp(const void *a, const void *b) { + const uint32_t x = ((const PSXDrawDistanceClampSite *)a)->address; + const uint32_t y = ((const PSXDrawDistanceClampSite *)b)->address; + return (x > y) - (x < y); +} + +int psx_draw_distance_set_clamp_sites(const PSXDrawDistanceClampSite *sites, + int count) { + if (!sites || count < 0) count = 0; + if (count > PSX_DRAW_DISTANCE_CLAMP_SITES_MAX) { + fprintf(stderr, + "psxrecomp: [[draw_distance.clamp]] lists %d sites; keeping " + "the first %d\n", + count, PSX_DRAW_DISTANCE_CLAMP_SITES_MAX); + count = PSX_DRAW_DISTANCE_CLAMP_SITES_MAX; + } + for (int i = 0; i < count; i++) { + s_sites[i] = sites[i]; + s_sites[i].address &= 0x1FFFFFFFu; + } + if (count > 1) qsort(s_sites, (size_t)count, sizeof s_sites[0], site_cmp); + s_site_count = count; + update_live(); + return count; +} + +int psx_draw_distance_clamp_site_count(void) { return s_site_count; } + +const PSXDrawDistanceClampSite *psx_draw_distance_clamp_find(uint32_t pc, + uint32_t insn) { + const uint32_t phys = pc & 0x1FFFFFFFu; + int lo = 0, hi = s_site_count; + while (lo < hi) { + const int mid = lo + (hi - lo) / 2; + if (s_sites[mid].address < phys) lo = mid + 1; + else hi = mid; + } + if (lo < s_site_count && s_sites[lo].address == phys && + s_sites[lo].expected == insn) + return &s_sites[lo]; + return NULL; +} + +/* mod_plugins.h. Process-wide host state: the session start resets it to off + * (main.cpp reset_mod_owned_presentation), so only a session whose plan + * activates a plugin that asks for it, never netplay, runs with it on. */ +int psx_mod_set_draw_distance_clamp(int enabled) { + g_psx_draw_distance_clamp = enabled ? 1u : 0u; + update_live(); + return s_site_count > 0; +} + +int psx_mod_draw_distance_clamp_enabled(void) { + return g_psx_draw_distance_clamp != 0u; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a14372ec..468f7f30 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -37,6 +37,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" +#include "draw_distance.h" #include "display_scanout.h" #include "pgxp.h" #include "interrupts.h" @@ -1546,6 +1547,7 @@ static void reset_mod_owned_presentation(void) { psx_mod_set_world_scene_predicate(nullptr); psx_mod_set_retained_scene_predicate(nullptr); psx_mod_set_adaptive_backdrop_preload(0); + (void)psx_mod_set_draw_distance_clamp(0); g_bezel_path.clear(); g_frame_interpolation_blend = g_frame_interpolation_blend_default; g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK; @@ -13527,6 +13529,17 @@ int main(int argc, char** argv) { gpu_ws_set_angle_sites( addresses.data(), expected.data(), (int)addresses.size()); } + { + /* [[draw_distance.clamp]]: stored for the interpreter; the + * switch stays off until a mod's activation turns it on. */ + std::vector sites; + sites.reserve(gc.draw_distance_clamp_sites.size()); + for (const auto& site : gc.draw_distance_clamp_sites) + sites.push_back({site.address, site.expected, site.reg, + site.max}); + (void)psx_draw_distance_set_clamp_sites(sites.data(), + (int)sites.size()); + } { std::vector addresses, expected, thresholds; std::vector object_regs, x_regs, z_regs, y_regs; diff --git a/runtime/tests/test_draw_distance_interp.c b/runtime/tests/test_draw_distance_interp.c new file mode 100644 index 00000000..24045dbc --- /dev/null +++ b/runtime/tests/test_draw_distance_interp.c @@ -0,0 +1,322 @@ +/* [[draw_distance.clamp]] in the dirty-RAM interpreter. + * + * Generated main-EXE code clamps a listed site's register before the guard + * runs (draw_distance_codegen_test covers the emit). Main-EXE code that runs + * from the interpreter goes through dirty_ram_interp.c instead, which looks + * the PC up in draw_distance.c's sorted store. This test runs the production + * code of both files: + * + * - the store: physical addresses, sort, word-guarded lookup, the cap and + * its log line, empty and NULL lists; + * - the switch: psx_mod_set_draw_distance_clamp's return value and the + * interpreter's live gate; + * - real interpreted instructions (exec_one_fetched) with the switch off + * and on: the sltiu and addiu guard forms, an unlisted twin, a + * mismatched word, a site outside the game's text image, and a negative + * index that the original guard still rejects. + * + * The sources are #included (gpu.c for the widescreen site stores the + * interpreter also consults) so the test can call the interpreter's static + * entry point; unreferenced code is dropped at link time (LTO plus section + * GC / dead_strip), so only the stubs below are needed. */ +#define PSX_HAS_GAME_DISPATCH 1 +#include "../src/draw_distance.c" +#include "../src/gpu.c" +#include "../src/dirty_ram_interp.c" + +#include +#include +#if defined(_WIN32) +#include +#define dup _dup +#define dup2 _dup2 +#define fileno _fileno +#else +#include +#endif + +/* ---- link stubs for code reachable from exec_one_fetched ------------------ */ +uint64_t s_frame_count; +uint32_t g_debug_current_func_addr; +uint32_t g_debug_last_store_pc; +uint32_t g_dirty_ram_code_gen; +uint64_t g_dispatch_static_hits; +int g_rfe_escape_pending; +int g_exc_escape_reason; +int g_psx_call_bail; +uint64_t g_psx_bail_first; +uint64_t g_psx_bail_resolved; +uint32_t i_stat; +uint32_t i_mask; +uint64_t psx_cycle_count; +uint32_t g_psx_mod_function_entry_hooks; +uint32_t g_psx_ram_size = PSX_MAIN_RAM_RETAIL_BYTES; +uint32_t g_psx_ram_mask = PSX_MAIN_RAM_RETAIL_BYTES - 1u; + +static uint8_t test_ram[0x00200000u]; + +uint8_t *memory_get_ram_ptr(void) { return test_ram; } +uint8_t psx_read_byte(uint32_t a) { return test_ram[a & 0x1FFFFFu]; } +uint16_t psx_read_half(uint32_t a) { + uint16_t v; memcpy(&v, test_ram + (a & 0x1FFFFEu), sizeof v); return v; +} +uint32_t psx_read_word(uint32_t a) { + uint32_t v; memcpy(&v, test_ram + (a & 0x1FFFFCu), sizeof v); return v; +} +uint8_t psx_cyc_load_byte(CPUState *cpu, uint32_t a, uint32_t rt, uint32_t m) { + (void)cpu; (void)rt; (void)m; return psx_read_byte(a); +} +uint32_t psx_cyc_lwc2_read(CPUState *cpu, uint32_t a) { (void)cpu; return psx_read_word(a); } +int dirty_ram_is_dirty(uint32_t phys) { (void)phys; return 1; } +int mdec_recently_active(uint32_t f) { (void)f; return 0; } +void gte_execute(CPUState *cpu, uint32_t cmd) { (void)cpu; (void)cmd; } +void gte_precision_store_word(uint32_t a, uint8_t r) { (void)a; (void)r; } +uint32_t gte_read_ctrl(CPUState *cpu, uint8_t r) { (void)cpu; (void)r; return 0; } +uint32_t gte_read_data(CPUState *cpu, uint8_t r) { (void)cpu; (void)r; return 0; } +void gte_write_ctrl(CPUState *cpu, uint8_t r, uint32_t v) { (void)cpu; (void)r; (void)v; } +void gte_write_data(CPUState *cpu, uint8_t r, uint32_t v) { (void)cpu; (void)r; (void)v; } +int overlay_loader_call_native(CPUState *cpu, uint32_t a) { (void)cpu; (void)a; return 0; } +void psx_bail_record(uint32_t ra, uint32_t sp, uint32_t pc, uint32_t gsp) { + (void)ra; (void)sp; (void)pc; (void)gsp; +} +void psx_break(CPUState *cpu, uint32_t code, uint32_t pc) { (void)cpu; (void)code; (void)pc; } +void psx_check_interrupts(struct CPUState *cpu) { (void)cpu; } +void psx_dispatch_call(CPUState *cpu, uint32_t t, uint32_t r) { (void)cpu; (void)t; (void)r; } +void psx_fatal_halt(const char *reason) { fprintf(stderr, "fatal: %s\n", reason); exit(2); } +void psx_get_freeze_diag(uint64_t *a, uint32_t *b, int *c, int *d, uint64_t *e, + uint64_t *f) { + (void)a; (void)b; (void)c; (void)d; (void)e; (void)f; +} +int psx_get_in_exception(void) { return 0; } +int psx_interrupts_checked_at_current_cycle(uint32_t pc) { (void)pc; return 1; } +void psx_pgxp_alu(struct CPUState *c, uint32_t i, uint32_t r, uint32_t a, uint32_t b) { + (void)c; (void)i; (void)r; (void)a; (void)b; +} +void psx_pgxp_cop2(struct CPUState *c, uint32_t i, uint32_t v, uint32_t a) { + (void)c; (void)i; (void)v; (void)a; +} +void psx_pgxp_load(struct CPUState *c, uint32_t i, uint32_t a, uint32_t v) { + (void)c; (void)i; (void)a; (void)v; +} +void psx_pgxp_muldiv(struct CPUState *c, uint32_t i, uint32_t h, uint32_t l, + uint32_t a, uint32_t b) { + (void)c; (void)i; (void)h; (void)l; (void)a; (void)b; +} +void psx_pgxp_store(struct CPUState *c, uint32_t i, uint32_t a, uint32_t v) { + (void)c; (void)i; (void)a; (void)v; +} +void psx_rfe_mark_escape(void) {} +int psx_syscall(CPUState *cpu, uint32_t code) { (void)cpu; (void)code; return 0; } +int psx_dispatch_game_compiled(CPUState *cpu, uint32_t a) { (void)cpu; (void)a; return 0; } +int psx_game_is_function_entry(uint32_t a) { (void)a; return 0; } +int psx_game_text_native_ok(uint32_t a) { (void)a; return 0; } +int psx_game_text_native_ok_full(uint32_t a) { (void)a; return 0; } +int psx_mod_function_entry(struct CPUState *cpu, uint32_t a) { (void)cpu; (void)a; return 0; } + +/* The game's text image for this test: [TEXT_LO, TEXT_HI). */ +#define TEXT_LO 0x80020000u +#define TEXT_HI 0x80030000u +int psx_game_address_in_text(uint32_t addr) { + const uint32_t phys = addr & 0x1FFFFFFFu; + return phys >= (TEXT_LO & 0x1FFFFFFFu) && phys < (TEXT_HI & 0x1FFFFFFFu); +} + +/* ---- harness --------------------------------------------------------------- */ +static int failures; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); \ + fputc('\n', stderr); \ + ++failures; \ + } \ + } while (0) + +enum { AT = 1, V0 = 2, T2 = 10 }; +#define SLTIU_T2_V0 0x2C4A01C0u /* sltiu t2, v0, 0x1C0 */ +#define ADDIU_AT_V0 0x2441FFFFu /* addiu at, v0, -1 */ +#define SLTIU_AT_AT 0x2C2101BFu /* sltiu at, at, 0x1BF */ + +static void step(CPUState *cpu, uint32_t pc, uint32_t insn) { + uint32_t next = 0; + cpu->pc = pc; + (void)exec_one_fetched(cpu, pc, insn, &next); +} + +static void put_word(uint32_t addr, uint32_t v) { memcpy(test_ram + (addr & 0x1FFFFFu), &v, 4); } + +static const char *s_capture_path = "draw_distance_interp_stderr.txt"; + +static void capture_stderr(void (*fn)(void), char *buf, size_t cap) { + FILE *tmp = fopen(s_capture_path, "w+b"); + buf[0] = '\0'; + if (!tmp) { + CHECK(0, "cannot open the stderr capture file %s", s_capture_path); + fn(); + return; + } + fflush(stderr); + int saved = dup(fileno(stderr)); + dup2(fileno(tmp), fileno(stderr)); + fn(); + fflush(stderr); + dup2(saved, fileno(stderr)); + close(saved); + rewind(tmp); + size_t n = fread(buf, 1, cap - 1, tmp); + buf[n] = '\0'; + fclose(tmp); + remove(s_capture_path); +} + +/* ---- store ----------------------------------------------------------------- */ +static PSXDrawDistanceClampSite big[300]; +static void set_300(void) { (void)psx_draw_distance_set_clamp_sites(big, 300); } + +static void test_store(void) { + const PSXDrawDistanceClampSite sites[] = { + { 0x80020010u, SLTIU_T2_V0, V0, 0x1BF }, + { 0xA0020000u, ADDIU_AT_V0, V0, 0x1BF }, /* KSEG1 spelling */ + { 0x00020008u, SLTIU_T2_V0, V0, 0x100 }, /* KUSEG spelling */ + }; + CHECK(psx_draw_distance_set_clamp_sites(sites, 3) == 3, "three stored"); + CHECK(psx_draw_distance_clamp_site_count() == 3, "count 3"); + CHECK(s_sites[0].address == 0x00020000u && s_sites[1].address == 0x00020008u && + s_sites[2].address == 0x00020010u, + "stored sorted and physical"); + const PSXDrawDistanceClampSite *s = psx_draw_distance_clamp_find(0x80020000u, ADDIU_AT_V0); + CHECK(s && s->reg == V0 && s->max == 0x1BF, "KSEG0 lookup of a KSEG1 entry"); + CHECK(psx_draw_distance_clamp_find(0x00020008u, SLTIU_T2_V0) != NULL, + "KUSEG lookup"); + CHECK(psx_draw_distance_clamp_find(0x80020010u, ADDIU_AT_V0) == NULL, + "another word at a listed address misses"); + CHECK(psx_draw_distance_clamp_find(0x80020004u, SLTIU_T2_V0) == NULL, + "between entries misses"); + CHECK(psx_draw_distance_clamp_find(0x80020014u, SLTIU_T2_V0) == NULL, + "above the last entry misses"); + CHECK(psx_draw_distance_clamp_find(0x8001FFFCu, SLTIU_T2_V0) == NULL, + "below the first entry misses"); + + CHECK(psx_draw_distance_set_clamp_sites(NULL, 5) == 0, "NULL list stores none"); + CHECK(psx_draw_distance_set_clamp_sites(sites, -1) == 0, "negative count stores none"); + CHECK(psx_draw_distance_clamp_find(0x80020010u, SLTIU_T2_V0) == NULL, "empty store misses"); + + for (int i = 0; i < 300; i++) { + big[i].address = 0x80040000u + 4u * (uint32_t)(299 - i); + big[i].expected = SLTIU_T2_V0; + big[i].reg = V0; + big[i].max = 0x1BF; + } + char log[512]; + capture_stderr(set_300, log, sizeof log); + CHECK(psx_draw_distance_clamp_site_count() == PSX_DRAW_DISTANCE_CLAMP_SITES_MAX, + "over-long list stored %d", psx_draw_distance_clamp_site_count()); + CHECK(strstr(log, "lists 300 sites; keeping the first 256") != NULL, + "overflow not logged (got \"%s\")", log); + int hits = 0; + for (int i = 0; i < 300; i++) + hits += psx_draw_distance_clamp_find(big[i].address, SLTIU_T2_V0) != NULL; + CHECK(hits == 256, "%d of the first 256 kept", hits); +} + +/* ---- switch ---------------------------------------------------------------- */ +static void test_switch(void) { + (void)psx_draw_distance_set_clamp_sites(NULL, 0); + CHECK(psx_mod_set_draw_distance_clamp(1) == 0, "no sites: the setter says so"); + CHECK(psx_mod_draw_distance_clamp_enabled(), "the switch is still recorded"); + CHECK(!g_psx_draw_distance_clamp_live, "no sites: interpreter gate stays off"); + const PSXDrawDistanceClampSite one = { 0x80020000u, SLTIU_T2_V0, V0, 0x1BF }; + (void)psx_draw_distance_set_clamp_sites(&one, 1); + CHECK(g_psx_draw_distance_clamp_live, "sites after the switch: gate on"); + CHECK(psx_mod_set_draw_distance_clamp(0) == 1, "sites: the setter says so"); + CHECK(!g_psx_draw_distance_clamp_live && !g_psx_draw_distance_clamp, + "off clears both"); + CHECK(psx_mod_set_draw_distance_clamp(7) == 1 && g_psx_draw_distance_clamp == 1u, + "any nonzero is on"); + (void)psx_mod_set_draw_distance_clamp(0); +} + +/* ---- interpreted instructions ---------------------------------------------- */ +static void test_interpreter(void) { + CPUState cpu; + memset(&cpu, 0, sizeof cpu); + const uint32_t site = TEXT_LO + 0x100u, twin = TEXT_LO + 0x200u; + const uint32_t a_site = TEXT_LO + 0x300u, a_guard = a_site + 4u; + const uint32_t outside = TEXT_HI + 0x100u; + put_word(site, SLTIU_T2_V0); + put_word(twin, SLTIU_T2_V0); + put_word(a_site, ADDIU_AT_V0); + put_word(a_guard, SLTIU_AT_AT); + put_word(outside, SLTIU_T2_V0); + const PSXDrawDistanceClampSite sites[] = { + { site, SLTIU_T2_V0, V0, 0x1BF }, + { a_site, ADDIU_AT_V0, V0, 0x1BF }, + { outside, SLTIU_T2_V0, V0, 0x1BF }, + }; + (void)psx_draw_distance_set_clamp_sites(sites, 3); + + /* Off: the original guard rejects the far index and keeps the register. */ + (void)psx_mod_set_draw_distance_clamp(0); + cpu.gpr[V0] = 0x300u; + step(&cpu, site, SLTIU_T2_V0); + CHECK(cpu.gpr[T2] == 0u && cpu.gpr[V0] == 0x300u, "off: vanilla reject"); + + (void)psx_mod_set_draw_distance_clamp(1); + /* On: kept in the last slot. */ + cpu.gpr[V0] = 0x300u; + step(&cpu, site, SLTIU_T2_V0); + CHECK(cpu.gpr[V0] == 0x1BFu && cpu.gpr[T2] == 1u, "on: clamped and kept (v0=%X t2=%u)", + cpu.gpr[V0], cpu.gpr[T2]); + /* Near indices are untouched. */ + cpu.gpr[V0] = 0x40u; + step(&cpu, site, SLTIU_T2_V0); + CHECK(cpu.gpr[V0] == 0x40u && cpu.gpr[T2] == 1u, "on: near index unchanged"); + /* The same instruction at an unlisted address stays vanilla. */ + cpu.gpr[V0] = 0x300u; + step(&cpu, twin, SLTIU_T2_V0); + CHECK(cpu.gpr[V0] == 0x300u && cpu.gpr[T2] == 0u, "on: unlisted twin vanilla"); + /* Another word at a listed address stays vanilla. */ + cpu.gpr[V0] = 0x300u; + step(&cpu, site, ADDIU_AT_V0); + CHECK(cpu.gpr[V0] == 0x300u && cpu.gpr[AT] == 0x2FFu, + "on: mismatched word vanilla"); + /* Outside the game's text image (captured overlay code): vanilla. */ + cpu.gpr[V0] = 0x300u; + step(&cpu, outside, SLTIU_T2_V0); + CHECK(cpu.gpr[V0] == 0x300u && cpu.gpr[T2] == 0u, "on: outside text vanilla"); + + /* The addiu form: `addiu at,v0,-1; sltiu at,at,0x1BF` rejects 0 (too + * near) and >= 0x1C0 (too far). The clamp keeps far, never near. */ + cpu.gpr[V0] = 0x900u; + step(&cpu, a_site, ADDIU_AT_V0); + step(&cpu, a_guard, SLTIU_AT_AT); + CHECK(cpu.gpr[V0] == 0x1BFu && cpu.gpr[AT] == 1u, "on: addiu form keeps far"); + cpu.gpr[V0] = 0u; + step(&cpu, a_site, ADDIU_AT_V0); + step(&cpu, a_guard, SLTIU_AT_AT); + CHECK(cpu.gpr[V0] == 0u && cpu.gpr[AT] == 0u, "on: addiu form still rejects near"); + /* A negative (wrapped) index is left for the original guard. */ + cpu.gpr[V0] = 0xFFFFFFF0u; + step(&cpu, site, SLTIU_T2_V0); + CHECK(cpu.gpr[V0] == 0xFFFFFFF0u && cpu.gpr[T2] == 0u, "on: negative index vanilla"); + + (void)psx_mod_set_draw_distance_clamp(0); + cpu.gpr[V0] = 0x900u; + step(&cpu, a_site, ADDIU_AT_V0); + step(&cpu, a_guard, SLTIU_AT_AT); + CHECK(cpu.gpr[V0] == 0x900u && cpu.gpr[AT] == 0u, "off again: vanilla"); +} + +int main(int argc, char **argv) { + if (argc > 1) s_capture_path = argv[1]; + test_store(); + test_switch(); + test_interpreter(); + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + puts("PASS draw-distance clamp store, switch and interpreted sites"); + return 0; +} diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index a4a02191..4fad1316 100644 --- a/runtime/tests/test_mod_owned_display_controls.py +++ b/runtime/tests/test_mod_owned_display_controls.py @@ -124,6 +124,7 @@ for reset in ( "psx_mod_set_world_scene_predicate(nullptr);", "psx_mod_set_retained_scene_predicate(nullptr);", "psx_mod_set_adaptive_backdrop_preload(0);", + "(void)psx_mod_set_draw_distance_clamp(0);", "g_bezel_path.clear();", "g_frame_interpolation_blend = g_frame_interpolation_blend_default;", "g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK;", From 6e1bb6e93de3bc8e41270559835b595800342d8e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:36:48 -0700 Subject: [PATCH 2/2] Refresh the BIOS emitter stamps The draw-distance clamp changes the emitter sources. Both BIOS backends regenerate byte-identically (no clamp sites outside a game config), so only the fingerprints move. Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- generated/SCPH1001.emitter.sha | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 150fa94d..e6b3e1e1 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -012434af40660484f2a6990dd9f6b2acb35d00bc514c3e85ba0a49ca211f3d33 +6fe3bdbc19cf0c36376b74000becb990391656826ae76ab216c0f27972295f26 diff --git a/generated/SCPH1001.emitter.sha b/generated/SCPH1001.emitter.sha index 07b17342..a91252d0 100644 --- a/generated/SCPH1001.emitter.sha +++ b/generated/SCPH1001.emitter.sha @@ -1 +1 @@ -aa351622ddd9c496b61bd0982e58230cb4fc3aac6e2a5a1e4899ccd51fc6dbe9 +77379a0dc8fbfdca8e116de0e9624032435f9470181207eaacd468653910142d