From 6959d4411d46a0dfb4bc1e13187d5b1eb9fbb3c1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:51:46 -0700 Subject: [PATCH 01/17] GL: upload the HD-authoritative raw mirror through a pixel-unpack buffer With an HD pack (R4's HD HUD) the raw VRAM mirror is refreshed from CPU VRAM by glTexSubImage2D from client memory into a texture that queued draws still sample. Apple's GL then makes the render thread wait for the GPU (GLDShareGroupRec::waitUsage). Uploading the same bytes through an orphaned GL_PIXEL_UNPACK_BUFFER queues the copy on the GPU instead. Same data, same order, same texture; PSX_GL_PBO_UPLOAD=0 restores the direct path. R4 race, release build, M4 (macOS sample of the render thread): pack_flush 9.8 % -> 1.0 % of samples (waitUsage 5.0 % -> gone), render-thread busy 51 % -> 43 % in the paired frame report. 10x captures of the same guest frames from one savestate hash identical with and without it. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 5201f60e..7c0b1d44 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -2899,6 +2899,17 @@ static const HiwTile *hiw_ensure(int x0, int x1) { } /* ---- coherency: hr FBO -> raw mirror (pack) ------------------------------ */ +#ifndef PSXGL_PIXEL_UNPACK_BUFFER +#define PSXGL_PIXEL_UNPACK_BUFFER 0x88EC +#endif +static GLuint s_raw_pbo = 0; +static int raw_upload_pbo_on(void) { + static int env = -1; + if (env < 0) { const char *e = getenv("PSX_GL_PBO_UPLOAD"); env = !(e && e[0] == '0'); } + if (!env) return 0; + if (!s_raw_pbo) p_glGenBuffers(1, &s_raw_pbo); + return s_raw_pbo != 0; +} static void pack_flush(void) { if (!s_raster_ok || !s_pack_dirty.set) return; /* A generated frame draws into its own surfaces: the raw mirror keeps @@ -2917,8 +2928,26 @@ static void pack_flush(void) { p_glActiveTexture(PSXGL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, s_raw_tex); glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, VRAM_W); - glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, PSXGL_RED_INTEGER, - GL_UNSIGNED_SHORT, s_vram + (size_t)y * VRAM_W + x); + if (raw_upload_pbo_on()) { + /* Through an orphaned pixel-unpack buffer: the copy into a + * texture the GPU is still sampling is queued on the GPU, where + * a client-memory upload makes the driver wait for those draws + * (Apple's GL: GLDShareGroupRec::waitUsage, about 40 % of the + * render thread's work in an R4 race with the HD HUD). Same + * bytes, same order. PSX_GL_PBO_UPLOAD=0 for the direct path. */ + const size_t rows = (size_t)(y + h - 1) * VRAM_W + (size_t)x + (size_t)w; + const size_t first = (size_t)y * VRAM_W + (size_t)x; + const size_t bytes = (rows - first) * sizeof(uint16_t); + p_glBindBuffer(PSXGL_PIXEL_UNPACK_BUFFER, s_raw_pbo); + p_glBufferData(PSXGL_PIXEL_UNPACK_BUFFER, (GLsizeiptr)bytes, + s_vram + first, PSXGL_STREAM_DRAW); + glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, PSXGL_RED_INTEGER, + GL_UNSIGNED_SHORT, (const void *)0); + p_glBindBuffer(PSXGL_PIXEL_UNPACK_BUFFER, 0); + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, w, h, PSXGL_RED_INTEGER, + GL_UNSIGNED_SHORT, s_vram + (size_t)y * VRAM_W + x); + } glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); return; } From c0e9c3ad96873cc4b300e43c872285d153863724 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:12:07 -0700 Subject: [PATCH 02/17] GL PBO upload: on by default only on macOS; reset the buffer with the context - Default on only on macOS (Apple's GL, where the stall was measured); elsewhere opt-in with PSX_GL_PBO_UPLOAD=1 until there is AMD/NVIDIA/Intel data. The Windows AMD iGPU run showed no stall and no change. - gl_renderer_shutdown deletes s_raw_pbo with the context and zeroes the name, so a lobby rematch's new context creates its own buffer. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 7c0b1d44..dc9fd566 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -2903,9 +2903,21 @@ static const HiwTile *hiw_ensure(int x0, int x1) { #define PSXGL_PIXEL_UNPACK_BUFFER 0x88EC #endif static GLuint s_raw_pbo = 0; +/* On by default only on macOS, where the stall was measured (Apple's GL on + * Metal); elsewhere opt-in until there is AMD/NVIDIA/Intel data (an AMD iGPU + * on Windows showed no stall and no change). PSX_GL_PBO_UPLOAD=0/1 overrides + * it on any platform. */ +#if defined(__APPLE__) +#define PSXGL_PBO_UPLOAD_DEFAULT 1 +#else +#define PSXGL_PBO_UPLOAD_DEFAULT 0 +#endif static int raw_upload_pbo_on(void) { static int env = -1; - if (env < 0) { const char *e = getenv("PSX_GL_PBO_UPLOAD"); env = !(e && e[0] == '0'); } + if (env < 0) { + const char *e = getenv("PSX_GL_PBO_UPLOAD"); + env = (e && e[0]) ? (e[0] != '0') : PSXGL_PBO_UPLOAD_DEFAULT; + } if (!env) return 0; if (!s_raw_pbo) p_glGenBuffers(1, &s_raw_pbo); return s_raw_pbo != 0; @@ -6163,8 +6175,12 @@ void gl_renderer_shutdown(void) { s_selected_bank_live_clut = s_tb_bank_live_clut = 0; if (s_ctx) { ensure_cpu(); + /* The upload buffer belongs to this context: a lobby rematch + * creates a new one, which must create its own. */ + if (s_raw_pbo) p_glDeleteBuffers(1, &s_raw_pbo); SDL_GL_DeleteContext(s_ctx); s_ctx = NULL; } + s_raw_pbo = 0; free(s_conv); s_conv = NULL; s_hq_n = 0; s_hq_vn = 0; /* queued window draws die with the context */ s_raster_ok = 0; From 13d59b7a39002d8f08615ae1d5e80155476d232c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:44:11 -0700 Subject: [PATCH 03/17] Mod plugins: netplay = "host_output" (read-only hooks stay on online) A plugin that only reads guest state to drive this player's host output (rumble, haptics) can now declare netplay = "host_output". Online it stays on per player: its function-entry hooks run in the shared simulation while the rest of the own-view plan is parked, a completion request is ignored and every psx_mod_write_* it attempts is refused, so it can never diverge peers. Offline it is an ordinary plugin. netplay_view_mods_test covers both. Co-Authored-By: Claude Opus 5.5 --- docs/NETPLAY.md | 10 ++++++ runtime/include/mod_packages.h | 6 ++++ runtime/src/mod_packages.cpp | 7 ++-- runtime/src/mod_runtime.cpp | 44 ++++++++++++++++++++++-- runtime/tests/test_netplay_view_mods.cpp | 25 +++++++++++++- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index d7679aa0..472dfd93 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -443,6 +443,16 @@ id = "r4.widescreen" netplay = "local_view" ``` +Two more plugin classes stay on per player. `netplay = "input"` transforms +only this player's pad before it is staged (`psx_mod_set_pad_transform`). +`netplay = "host_output"` only reads guest state to drive this player's host +output (rumble, haptics): its function-entry hooks run in the shared +simulation (including rollback resimulation), a completion request from them +is ignored, and every `psx_mod_write_*` they attempt is refused while the +match runs, so they cannot diverge the peers. Host-side calls that must not +repeat on a resim (e.g. `psx_mod_set_host_rumble`) check +`psx_netplay_is_resimulating()` themselves. + At a netplay session start the runtime resolves the player's own selection and keeps only features whose every contribution is such a plugin (no EXE or disc write, overlay or derived disc; `mod_runtime_commit_netplay_view`). They diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index d989083e..7e9117b7 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -223,6 +223,11 @@ struct ModPlugin { * before it is staged (psx_mod_set_pad_transform). Online it stays on per * player: the post-transform pad is what every peer simulates. */ bool netplay_input = false; + /* netplay = "host_output": the plugin only reads guest state and drives + * host output (rumble, haptics). Online it stays on per player and its + * hooks run in the shared simulation, but every guest write it attempts + * is refused there, so it can never diverge the peers. */ + bool netplay_host_output = false; }; struct ModResource { @@ -388,6 +393,7 @@ struct ModResolution { std::string feature_id; bool netplay_local_view = false; bool netplay_input = false; + bool netplay_host_output = false; }; std::vector plugins; struct Resource { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 35979a73..7138cc3d 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -2599,11 +2599,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, const std::string netplay = toml::find_or(v, "netplay", ""); if (!netplay.empty() && netplay != "local_view" && - netplay != "input") + netplay != "input" && netplay != "host_output") throw std::runtime_error( - "plugin netplay must be \"local_view\" or \"input\""); + "plugin netplay must be \"local_view\", \"input\" " + "or \"host_output\""); plugin.netplay_local_view = netplay == "local_view"; plugin.netplay_input = netplay == "input"; + plugin.netplay_host_output = netplay == "host_output"; } read_conditions(v, out.options, plugin.feature_id, plugin.when, "plugin"); @@ -3945,6 +3947,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.feature_id = plugin->feature_id; resolved.netplay_local_view = plugin->netplay_local_view; resolved.netplay_input = plugin->netplay_input; + resolved.netplay_host_output = plugin->netplay_host_output; result.plugins.push_back(std::move(resolved)); } for (const ModResource& resource : package->resources) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 86913b04..2b26be00 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2285,7 +2285,8 @@ std::vector mod_runtime_netplay_view_features(const ModResolution& for (const ModResolution::Plugin& p : plan.plugins) { const std::string key = p.package_id + "/" + p.feature_id; auto it = ok.find(key); - const bool good = p.netplay_local_view || p.netplay_input; + const bool good = p.netplay_local_view || p.netplay_input || + p.netplay_host_output; ok[key] = it == ok.end() ? good : (it->second && good); } for (const ModResolution::Write& w : plan.writes) @@ -2930,7 +2931,9 @@ extern "C" int psx_mod_current_resource_bytes(const char* resource_id, return 0; } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_byte(address, value); } @@ -2938,7 +2941,9 @@ extern "C" uint16_t psx_mod_read_half(uint32_t address) { return psx_read_half(address); } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_half(uint32_t address, uint16_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_half(address, value); } @@ -2946,11 +2951,14 @@ extern "C" uint32_t psx_mod_read_word(uint32_t address) { return psx_read_word(address); } +static bool mod_host_output_write_blocked(void); extern "C" void psx_mod_write_word(uint32_t address, uint32_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_word(address, value); } extern "C" void psx_mod_write_code_word(uint32_t address, uint32_t value) { + if (mod_host_output_write_blocked()) return; psx_host_write_word(address, value); dirty_ram_mark_executable_range(address & 0x1FFFFFFFu, 4u); } @@ -3135,11 +3143,43 @@ extern "C" void psx_mod_instruction(CPUState* cpu, uint32_t address, uint32_t in static int game_netplay_function_entry(CPUState* cpu, uint32_t address); +/* Netplay host-output plugins ([[plugin]] netplay = "host_output") run their + * entry hooks in the shared simulation while the rest of the own-view plan is + * parked. They observe only: a completion request is ignored and guest + * writes are refused (mod_host_output_write_blocked). */ +static void run_host_output_hooks(CPUState* cpu, uint32_t address) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + const auto& table = active_function_entry_hooks(); + const uint32_t key = function_entry_key(address); + auto it = std::lower_bound( + table.begin(), table.end(), key, + [](const ActiveFunctionEntryHook& hook, uint32_t k) { return hook.key < k; }); + for (; it != table.end() && it->key == key; ++it) { + if (!it->plugin || !it->plugin->netplay_host_output || !it->callback) + continue; + PluginCallbackScope scope(s, it->plugin, cpu); + it->callback(cpu, address); + s.current_function_finished = false; + } +} + +/* A host-output plugin may not change guest memory while a netplay plan is + * live: anything it wrote would exist on one peer only. */ +static bool mod_host_output_write_blocked(void) { + const auto& s = PSXRecompV4::state(); + return s.netplay_view_plan && s.current_plugin && + s.current_plugin->netplay_host_output; +} + extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_function_entry_hooks || !cpu) return 0; RuntimeMods& s = state(); - if (mod_plan_hooks_parked()) return game_netplay_function_entry(cpu, address); + if (mod_plan_hooks_parked()) { + run_host_output_hooks(cpu, address); + return game_netplay_function_entry(cpu, address); + } const auto& table = active_function_entry_hooks(); const uint32_t key = function_entry_key(address); auto it = std::lower_bound( diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index 757fb06a..f1fee3c2 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -139,6 +139,13 @@ static void sim_entry(CPUState*, uint32_t) { sim_hits++; } static void view_activate(void) { view_activations++; } static void sim_activate(void) { sim_activations++; } static void view_vblank(void) { vblanks++; } +static int out_hits, out_activations; +static void out_entry(CPUState* cpu, uint32_t) { + out_hits++; + psx_mod_write_byte(0x80004000u, 0x5Au); /* must be refused online */ + psx_mod_finish_function(cpu); /* must be ignored online */ +} +static void out_activate(void) { out_activations++; } static std::string package(const std::string& id, const std::string& feature, const std::string& plugin, const std::string& extra) { @@ -157,12 +164,15 @@ int main() { package("view.pkg", "wide", "view.wide", "netplay = \"local_view\"\n")); write_text(root / "bundled/sim.pkg/1.0.0/manifest.toml", package("sim.pkg", "cheat", "sim.cheat", "")); + write_text(root / "bundled/out.pkg/1.0.0/manifest.toml", + package("out.pkg", "rumble", "out.rumble", "netplay = \"host_output\"\n")); write_text(root / "bundled/bad.pkg/1.0.0/manifest.toml", package("bad.pkg", "x", "bad.x", "netplay = \"everywhere\"\n")); const std::string state = "format_version = 2\n" "[[feature]]\npackage_id = \"view.pkg\"\nid = \"wide\"\nenabled = true\n" - "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n"; + "[[feature]]\npackage_id = \"sim.pkg\"\nid = \"cheat\"\nenabled = true\n" + "[[feature]]\npackage_id = \"out.pkg\"\nid = \"rumble\"\nenabled = true\n"; write_text(root / "state.toml", state); check(psx_mod_register_function_entry_plugin("view.wide", 0x80003000u, view_entry), @@ -173,6 +183,9 @@ int main() { psx_mod_register_activation_plugin("sim.cheat", sim_activate), "register activations"); check(psx_mod_register_vblank_plugin("view.wide", view_vblank), "register vblank"); + check(psx_mod_register_function_entry_plugin("out.rumble", 0x80003200u, out_entry) && + psx_mod_register_activation_plugin("out.rumble", out_activate), + "register host-output hook"); std::string error; check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-VIEW", 0x80002000u, {}, &error), @@ -186,6 +199,10 @@ int main() { psx_mod_function_entry(&cpu, 0x80003000u); psx_mod_function_entry(&cpu, 0x80003100u); check(view_hits == 1 && sim_hits == 1, "offline hooks run in the game"); + check(psx_mod_function_entry(&cpu, 0x80003200u) == 1 && out_hits == 1 && + psx_read_byte(0x80004000u) == 0x5Au, + "offline a host-output hook is an ordinary hook"); + psx_write_byte(0x80004000u, 0); /* Netplay: only the local-view feature remains, parked outside the scope. */ view_hits = sim_hits = view_activations = sim_activations = vblanks = 0; @@ -195,6 +212,12 @@ int main() { mod_runtime_activate_plugins(); check(view_activations == 1 && sim_activations == 0, "only the local-view plugin activates in a match"); + check(out_activations == 2, "the host-output plugin activates in a match too"); + out_hits = 0; + check(psx_mod_function_entry(&cpu, 0x80003200u) == 0 && out_hits == 1, + "a host-output hook runs in the shared simulation, cannot finish it"); + check(psx_read_byte(0x80004000u) == 0, + "a host-output plugin's guest write is refused online"); psx_mod_function_entry(&cpu, 0x80003000u); psx_mod_function_entry(&cpu, 0x80003100u); mod_runtime_on_vblank(); From 6162c4e30df986f406a2bf9a289be0a3ebd29e0d Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:30:59 -0700 Subject: [PATCH 04/17] SPU: anchor the restored sample phase to the restored clock at load The SPU's guest-clock phase was applied lazily at the next catch-up, after the guest had run on, so the phase was taken against a later clock and lost; a rollback replay rendered SPU samples at other guest cycles than the live run (voice / SPU RAM split with an identical CPU path, PSX_RB_DET_SELFTEST). boot_state writes CLOCK before SPU, so the load anchors it immediately. Co-Authored-By: Claude Opus 5.5 --- runtime/src/spu.c | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/runtime/src/spu.c b/runtime/src/spu.c index cd0d2189..4a4cdf05 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -2028,9 +2028,20 @@ int spu_snapshot_read(const uint8_t *p, uint32_t len) { } uint32_t phase = 0; if (len == spu_snapshot_bytes() && !pst_r_u32(&r, &phase)) return 0; - /* Applied at the next catch-up, once the guest clock is restored too. */ - s_clock_carry = phase % SPU_CYCLES_PER_SAMPLE; - s_clock_valid = 0; + /* Anchor the SPU's sample phase to the restored clock NOW. boot_state + * writes CLOCK before SPU, so the clock is already restored here. It used + * to be applied lazily at the next catch-up -- after the guest had run on, + * so the phase was taken against a later clock and lost: a rollback + * replay rendered SPU samples at other guest cycles than the live run + * (voice state / SPU RAM split with identical CPU paths, + * PSX_RB_DET_SELFTEST). */ + { + const uint64_t now = psx_get_cycle_count(); + const uint32_t carry = phase % SPU_CYCLES_PER_SAMPLE; + s_clock = now >= carry ? now - carry : now; + s_clock_carry = 0; + s_clock_valid = 1; + } spu_output_flush(); return 1; } From 1bc74b8190ced18e46a10505173d40b88bebdba6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:40:27 -0700 Subject: [PATCH 05/17] host_output hooks observe a CPU copy; guest-mutating services refuse Review (mstan): a host-output callback could change the shared CPU (GPR2 set to 0x12345678 survived an online psx_mod_function_entry). Each host-output callback now gets a private CPUState copy whose changes are discarded, and while one runs (psx_mod_host_output_scope) every guest-mutating mod service refuses: memory writes, host_write_ram, call_guest*, DMA / SPU / VRAM uploads, guest and GPU-DMA allocations, texture packet memory, the cycle-scale gate and finish_function. netplay_view_mods_test reproduces the review case (GPR2 + PC changed in the callback) and checks the shared CPU is byte-identical after the hook; it fails without the copy. Co-Authored-By: Claude Opus 5.5 --- docs/NETPLAY.md | 10 ++++--- runtime/src/mod_guest_services.c | 20 +++++++++++++- runtime/src/mod_runtime.cpp | 34 ++++++++++++++++++------ runtime/src/mod_texture_banks.c | 2 ++ runtime/src/psx_cycles.c | 6 ++++- runtime/tests/test_netplay_view_mods.cpp | 17 ++++++++++-- 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 472dfd93..3e30d4f9 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -447,9 +447,13 @@ Two more plugin classes stay on per player. `netplay = "input"` transforms only this player's pad before it is staged (`psx_mod_set_pad_transform`). `netplay = "host_output"` only reads guest state to drive this player's host output (rumble, haptics): its function-entry hooks run in the shared -simulation (including rollback resimulation), a completion request from them -is ignored, and every `psx_mod_write_*` they attempt is refused while the -match runs, so they cannot diverge the peers. Host-side calls that must not +simulation (including rollback resimulation) on a private copy of the CPU +state, whose register changes are discarded; a completion request from them +is ignored; and every guest-mutating mod service (`psx_mod_write_*`, +`psx_mod_host_write_ram`, `psx_mod_call_guest*`, DMA / SPU / VRAM uploads, +guest allocations, the cycle-scale gate) refuses while one runs +(`psx_mod_host_output_scope()`), so the shared simulation is unchanged by +them. This is a determinism contract for trusted plugins, not a sandbox. Host-side calls that must not repeat on a resim (e.g. `psx_mod_set_host_rumble`) check `psx_netplay_is_resimulating()` themselves. diff --git a/runtime/src/mod_guest_services.c b/runtime/src/mod_guest_services.c index 57c80a78..d23d216f 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -11,9 +11,14 @@ #include "spu.h" #include +/* mod_runtime.cpp: 1 while a netplay host-output plugin's hook runs; every + * guest-mutating service refuses then (docs/NETPLAY.md). */ +int psx_mod_host_output_scope(void); + uint32_t psx_mod_call_guest(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3) { + if (psx_mod_host_output_scope()) return 0; uint32_t regs[32]; const uint32_t pc = cpu->pc, hi = cpu->hi, lo = cpu->lo; memcpy(regs, cpu->gpr, sizeof regs); @@ -67,6 +72,7 @@ uint32_t psx_mod_call_guest_uncharged_counted(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t budget_cycles, int* charged, uint64_t* counted_cycles) { + if (psx_mod_host_output_scope()) { if (charged) *charged = 0; return 0; } PsxCycleFreeze save; CallerTiming timing; timing_save(&timing, cpu); @@ -85,12 +91,14 @@ uint32_t psx_mod_call_guest_uncharged(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t budget_cycles, int* charged) { + if (psx_mod_host_output_scope()) { if (charged) *charged = 0; return 0; } return psx_mod_call_guest_uncharged_counted(cpu, function, return_address, a0, a1, a2, a3, budget_cycles, charged, NULL); } int psx_mod_dma_write_ram(uint32_t address, const void* data, uint32_t bytes, int lba) { + if (psx_mod_host_output_scope()) return 0; return dma_host_cdrom_write(address, (const uint8_t*)data, bytes, lba); } @@ -101,6 +109,7 @@ static int ram_span(uint32_t address, uint32_t bytes) { } int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes) { + if (psx_mod_host_output_scope()) return 0; const uint8_t* p = (const uint8_t*)data; if ((!p && bytes) || !ram_span(address, bytes)) return 0; while (bytes && (address & 3u)) { psx_host_write_byte(address++, *p++); --bytes; } @@ -118,22 +127,30 @@ int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes) { #define SPU_CONTROL 0x1F801DAAu int psx_mod_spu_sample_bank(uint32_t bank,const void *adpcm,uint32_t bytes) { + if (psx_mod_host_output_scope()) return 0; return spu_register_sample_bank(bank,adpcm,bytes); } int psx_mod_spu_bind_voice_bank(unsigned voice,uint32_t bank) { + if (psx_mod_host_output_scope()) return 0; return spu_bind_next_voice_bank(voice,bank); } int psx_mod_spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode) { + if (psx_mod_host_output_scope()) return 0; return spu_private_voice_play(slot,bank,regs,mode); } -void psx_mod_spu_private_voice_stop(uint32_t mask) {spu_private_voice_stop(mask);} +void psx_mod_spu_private_voice_stop(uint32_t mask) { + if (psx_mod_host_output_scope()) return; + spu_private_voice_stop(mask); +} int psx_mod_spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right) { + if (psx_mod_host_output_scope()) return 0; return spu_private_voice_volume(slot,left,right); } uint32_t psx_mod_spu_private_voice_active(void) {return spu_private_voice_active();} int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, uint32_t bytes, int stop_after) { + if (psx_mod_host_output_scope()) return 0; if ((spu_address & 7u) || (guest_source & 3u) || (bytes & 3u) || spu_address >= SPU_RAM_BYTES || bytes > SPU_RAM_BYTES || !ram_span(guest_source, bytes)) @@ -148,6 +165,7 @@ int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, int psx_mod_psyq_load_image(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint32_t guest_source) { + if (psx_mod_host_output_scope()) return 0; if (!w || w > 1024u || !h || h > 512u || x >= 1024u || y >= 512u || (guest_source & 3u)) return 0; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 2b26be00..f451293b 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2963,13 +2963,16 @@ extern "C" void psx_mod_write_code_word(uint32_t address, uint32_t value) { dirty_ram_mark_executable_range(address & 0x1FFFFFFFu, 4u); } +extern "C" int psx_mod_host_output_scope(void); extern "C" uint32_t psx_mod_alloc_guest_memory(uint32_t size, uint32_t alignment) { + if (psx_mod_host_output_scope()) return 0; return psx_mod_memory_alloc(size, alignment); } extern "C" uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t size, uint32_t alignment) { + if (psx_mod_host_output_scope()) return 0; return psx_mod_gpu_dma_memory_alloc(size, alignment); } @@ -3086,6 +3089,7 @@ extern "C" int psx_mod_register_function_filter_plugin( extern "C" int psx_mod_finish_function(CPUState* cpu) { using namespace PSXRecompV4; RuntimeMods& s = state(); + if (psx_mod_host_output_scope()) return 0; if (!cpu || s.current_function_cpu != cpu || !s.current_plugin) return 0; s.current_function_finished = true; return 1; @@ -3145,8 +3149,18 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address); /* Netplay host-output plugins ([[plugin]] netplay = "host_output") run their * entry hooks in the shared simulation while the rest of the own-view plan is - * parked. They observe only: a completion request is ignored and guest - * writes are refused (mod_host_output_write_blocked). */ + * parked. They observe only: each callback gets a private copy of the CPU + * state (register changes to it are discarded), a completion request is + * ignored, and every guest-mutating mod service refuses while one runs + * (psx_mod_host_output_scope), so the shared simulation is bit-identical + * with or without them. */ +static int s_host_output_depth; + +extern "C" int psx_mod_host_output_scope(void) { + return s_host_output_depth > 0 && + PSXRecompV4::state().netplay_view_plan ? 1 : 0; +} + static void run_host_output_hooks(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; RuntimeMods& s = state(); @@ -3158,18 +3172,22 @@ static void run_host_output_hooks(CPUState* cpu, uint32_t address) { for (; it != table.end() && it->key == key; ++it) { if (!it->plugin || !it->plugin->netplay_host_output || !it->callback) continue; - PluginCallbackScope scope(s, it->plugin, cpu); - it->callback(cpu, address); + CPUState view = *cpu; /* observation copy */ + PluginCallbackScope scope(s, it->plugin, &view); + ++s_host_output_depth; + it->callback(&view, address); + --s_host_output_depth; s.current_function_finished = false; } } -/* A host-output plugin may not change guest memory while a netplay plan is - * live: anything it wrote would exist on one peer only. */ +/* Memory writes from a host-output plugin while a netplay plan is live would + * exist on one peer only. */ static bool mod_host_output_write_blocked(void) { const auto& s = PSXRecompV4::state(); - return s.netplay_view_plan && s.current_plugin && - s.current_plugin->netplay_host_output; + return psx_mod_host_output_scope() || + (s.netplay_view_plan && s.current_plugin && + s.current_plugin->netplay_host_output); } extern "C" int psx_mod_function_entry(CPUState* cpu, uint32_t address) { diff --git a/runtime/src/mod_texture_banks.c b/runtime/src/mod_texture_banks.c index d5767db1..d58d9f0f 100644 --- a/runtime/src/mod_texture_banks.c +++ b/runtime/src/mod_texture_banks.c @@ -54,8 +54,10 @@ const uint16_t* mod_texture_bank_pixels(uint16_t id, uint32_t* w, uint32_t* h) { return banks[id].pixels; } +int psx_mod_host_output_scope(void); uint32_t psx_mod_alloc_texture_packet_memory(uint32_t size, uint32_t alignment) { uint32_t addr; + if (psx_mod_host_output_scope()) return 0; if (arena_count == 16u || size < 40u) return 0; addr = psx_mod_gpu_dma_memory_alloc(size, alignment); if (!addr) return 0; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index df936415..a4e39015 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -104,7 +104,11 @@ void psx_guest_cycle_scale_vblank(void) { } /* Public mod API (mod_plugins.h): the mod gate. */ -void psx_mod_set_guest_cycle_scale_gate(int open) { psx_guest_cycle_scale_gate_open(open); } +int psx_mod_host_output_scope(void); +void psx_mod_set_guest_cycle_scale_gate(int open) { + if (psx_mod_host_output_scope()) return; + psx_guest_cycle_scale_gate_open(open); +} uint32_t psx_mod_guest_cycle_scale(void) { return s_psx_gcs_live; } void psx_guest_cycle_scale_snapshot(uint32_t out[3]) { diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index f1fee3c2..609289f0 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "mod_packages.h" #include "mod_plugins.h" +#include #include "psx_sha256.h" #include "gpu.h" @@ -139,9 +140,13 @@ static void sim_entry(CPUState*, uint32_t) { sim_hits++; } static void view_activate(void) { view_activations++; } static void sim_activate(void) { sim_activations++; } static void view_vblank(void) { vblanks++; } -static int out_hits, out_activations; +static int out_hits, out_activations, out_scope; +extern "C" int psx_mod_host_output_scope(void); static void out_entry(CPUState* cpu, uint32_t) { out_hits++; + out_scope = psx_mod_host_output_scope(); + cpu->gpr[2] = 0x12345678u; /* review repro: must not stick */ + cpu->pc = 0x80001234u; psx_mod_write_byte(0x80004000u, 0x5Au); /* must be refused online */ psx_mod_finish_function(cpu); /* must be ignored online */ } @@ -200,8 +205,9 @@ int main() { psx_mod_function_entry(&cpu, 0x80003100u); check(view_hits == 1 && sim_hits == 1, "offline hooks run in the game"); check(psx_mod_function_entry(&cpu, 0x80003200u) == 1 && out_hits == 1 && - psx_read_byte(0x80004000u) == 0x5Au, + psx_read_byte(0x80004000u) == 0x5Au && !out_scope, "offline a host-output hook is an ordinary hook"); + cpu = CPUState{}; psx_write_byte(0x80004000u, 0); /* Netplay: only the local-view feature remains, parked outside the scope. */ @@ -214,8 +220,15 @@ int main() { "only the local-view plugin activates in a match"); check(out_activations == 2, "the host-output plugin activates in a match too"); out_hits = 0; + cpu = CPUState{}; + cpu.gpr[31] = 0x80005550u; + const CPUState before = cpu; check(psx_mod_function_entry(&cpu, 0x80003200u) == 0 && out_hits == 1, "a host-output hook runs in the shared simulation, cannot finish it"); + check(out_scope == 1 && !psx_mod_host_output_scope(), + "host-output scope is set only while its hook runs"); + check(cpu.gpr[2] == 0 && std::memcmp(&cpu, &before, sizeof cpu) == 0, + "a host-output hook's CPU register changes do not reach the shared CPU"); check(psx_read_byte(0x80004000u) == 0, "a host-output plugin's guest write is refused online"); psx_mod_function_entry(&cpu, 0x80003000u); From 37792ac4af65b28cd5d1c217b98d78e993d869c4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 11 Oct 2026 23:43:34 -0700 Subject: [PATCH 06/17] Disc geometry from cue TRACK mode / sector-0 sync, never file length A raw dump with trailing bytes can be a multiple of 2048 and mounted as a cooked ISO (Legend of Dragoon disc 1: PVD read as zeros, BIOS SystemError B/906). Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/CMakeLists.txt | 10 + runtime/include/cue_sheet.h | 5 + runtime/include/iso_reader.h | 132 +-- runtime/src/cue_sheet.cpp | 16 +- runtime/src/iso_reader.cpp | 1108 +++++++++++--------- runtime/src/mod_runtime.cpp | 10 +- runtime/tests/test_iso_reader_geometry.cpp | 136 +++ runtime/tests/test_mod_runtime.cpp | 3 + 8 files changed, 837 insertions(+), 583 deletions(-) create mode 100644 runtime/tests/test_iso_reader_geometry.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5e84519f..f0732674 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1121,6 +1121,16 @@ if(BUILD_TESTING) target_link_libraries(iso_reader_pregap_test PRIVATE chdr-static) add_test(NAME iso_reader_pregap_test COMMAND iso_reader_pregap_test) + # Raw vs cooked geometry comes from the cue TRACK mode / sector-0 sync, + # never from file length. + add_executable(iso_reader_geometry_test + tests/test_iso_reader_geometry.cpp + src/iso_reader.cpp + src/cue_sheet.cpp) + target_include_directories(iso_reader_geometry_test PRIVATE include ${PSXRECOMP_ROOT}/recompiler/include) + target_link_libraries(iso_reader_geometry_test PRIVATE chdr-static) + add_test(NAME iso_reader_geometry_test COMMAND iso_reader_geometry_test) + add_executable(iso_reader_sbi_test tests/test_iso_reader_sbi.cpp src/iso_reader.cpp diff --git a/runtime/include/cue_sheet.h b/runtime/include/cue_sheet.h index 7900c35d..79a2f3ec 100644 --- a/runtime/include/cue_sheet.h +++ b/runtime/include/cue_sheet.h @@ -45,6 +45,11 @@ struct CueTrackRef { // shifting every later disc LBA (and the TOC) accordingly. uint32_t pregap = 0; uint32_t postgap = 0; + // Bytes per stored sector declared by the TRACK mode: 2352 for AUDIO and + // every "/2352" mode, 2048 or 2336 for the cooked modes. 0 = the mode named + // no size (unknown). The cue is the authority for a file's geometry; the + // file length is not (dumps can carry a partial trailing sector). + uint32_t sector_size = 0; }; struct CueSheet { diff --git a/runtime/include/iso_reader.h b/runtime/include/iso_reader.h index ff75e9c3..7737d494 100644 --- a/runtime/include/iso_reader.h +++ b/runtime/include/iso_reader.h @@ -1,12 +1,12 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include /** * ISO/BIN/CUE Reader for PS1 CD-ROM images @@ -20,10 +20,10 @@ * - Read file contents by path */ -namespace PS1 { - -struct CHDState; - +namespace PS1 { + +struct CHDState; + /** * Information about a file entry in the ISO filesystem */ @@ -44,21 +44,21 @@ struct RootDirectoryInfo { /** * A CD track parsed from the .cue sheet (or the synthesized single data - * track for a bare .bin/.iso). Multi-track discs can carry a + * track for a bare .bin/.iso). Multi-track discs can carry a * Red Book CD-DA audio track after the data track; the CD model's TOC * commands (GetTN/GetTD) must report them. */ -struct CDTrack { +struct CDTrack { int number; // 1-based track number bool is_audio; // true = CD-DA audio (Red Book); false = data - uint32_t start_lba; // disc-relative start LBA (cue INDEX 01; track 1 = 0). + uint32_t start_lba; // disc-relative start LBA (cue INDEX 01; track 1 = 0). // Single-FILE cues: equals the .bin-relative INDEX time. // Multi-FILE cues (redump "(Track N).bin" dumps): the - // owning file's first disc sector plus its INDEX time. - // Either way, plus every cue PREGAP/POSTGAP before it - // (gaps the dump does not store). - uint32_t pregap_lba; // disc-relative start of the track's pregap: its cue - // PREGAP if any, else INDEX 00, else INDEX 01 + // owning file's first disc sector plus its INDEX time. + // Either way, plus every cue PREGAP/POSTGAP before it + // (gaps the dump does not store). + uint32_t pregap_lba; // disc-relative start of the track's pregap: its cue + // PREGAP if any, else INDEX 00, else INDEX 01 }; /** @@ -70,23 +70,23 @@ struct CDTrack { struct BinSegment { std::string path; // resolved on-disk path std::ifstream file; // opened for the reader's lifetime - uint32_t start_lba; // disc sector of this file's first stored sector + uint32_t start_lba; // disc sector of this file's first stored sector uint32_t sector_count; // sectors stored in this file bool raw; // 2352-byte raw sectors (BIN) vs 2048 (ISO) -}; - -/** - * A contiguous run of disc sectors. A stored span maps onto sectors of one - * BinSegment; a virtual span is a cue PREGAP/POSTGAP that the dump does not - * store and that reads back as zero (silence), as the CHD path already does. - * Without PREGAP/POSTGAP lines each file is exactly one stored span, so the - * disc layout is the plain concatenation of the files. - */ -struct BinSpan { - uint32_t disc_start; // first disc-relative sector - uint32_t sector_count; - int segment; // index into segments_, or -1 for a virtual gap - uint32_t file_sector; // first sector within the segment's file +}; + +/** + * A contiguous run of disc sectors. A stored span maps onto sectors of one + * BinSegment; a virtual span is a cue PREGAP/POSTGAP that the dump does not + * store and that reads back as zero (silence), as the CHD path already does. + * Without PREGAP/POSTGAP lines each file is exactly one stored span, so the + * disc layout is the plain concatenation of the files. + */ +struct BinSpan { + uint32_t disc_start; // first disc-relative sector + uint32_t sector_count; + int segment; // index into segments_, or -1 for a virtual gap + uint32_t file_sector; // first sector within the segment's file }; class ISOReader { @@ -103,11 +103,23 @@ public: /** * Open an ISO/BIN file for reading - * @param filename Path to .iso, .bin, .img, .car, .cue, or .chd file + * @param filename Path to .iso, .bin, .img, .car, .cue, or .chd file * @return true if opened successfully, false otherwise */ bool Open(const std::string& filename); + /** + * Sector geometry of the first stored file behind an image, decided the + * same way Open() decides it: a .cue's TRACK mode is authoritative; a bare + * file is raw when sector 0 starts with the CD sync pattern. File length + * is never the evidence (raw dumps can be a multiple of 2048). + * @param path .cue or bare .bin/.iso/.img + * @param raw set to true for 2352-byte raw sectors, false for 2048 + * @return false when the image cannot be opened or its geometry is not + * mountable (conflicting or MODE2/2336 track modes) + */ + static bool FirstFileIsRaw(const std::string& path, bool* raw); + /** * Close the currently open file */ @@ -127,15 +139,15 @@ public: * @param buffer Buffer to read into (must be at least 2352 bytes) * @return true if the image stores raw sectors and read succeeded */ - bool ReadRawSector(uint32_t lba, uint8_t* buffer); - - /** - * Read generated or SBI-replacement subchannel Q for a disc LBA. - * SBI records intentionally carry invalid CRC data; valid is false for - * those sectors so the CD controller can retain its last valid Q packet. - */ - bool ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const; - bool HasSubChannelReplacements() const { return !subq_replacements_.empty(); } + bool ReadRawSector(uint32_t lba, uint8_t* buffer); + + /** + * Read generated or SBI-replacement subchannel Q for a disc LBA. + * SBI records intentionally carry invalid CRC data; valid is false for + * those sectors so the CD controller can retain its last valid Q packet. + */ + bool ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const; + bool HasSubChannelReplacements() const { return !subq_replacements_.empty(); } /** * Check if a file is currently open @@ -163,11 +175,11 @@ public: /** * CD-track TOC accessors (multi-track .cue support). * TrackCount() is >= 1 (a bare image synthesizes one data track). - * TrackStartLBA(n)/TrackPregapLBA(n)/TrackIsAudio(n) take a 1-based track number. + * TrackStartLBA(n)/TrackPregapLBA(n)/TrackIsAudio(n) take a 1-based track number. */ - int TrackCount() const; - uint32_t TrackStartLBA(int track) const; - uint32_t TrackPregapLBA(int track) const; + int TrackCount() const; + uint32_t TrackStartLBA(int track) const; + uint32_t TrackPregapLBA(int track) const; bool TrackIsAudio(int track) const; /** @@ -237,23 +249,23 @@ private: std::vector ListFilesByLBA(uint32_t lba, uint32_t dir_size); /** - * Helper: find the span containing a disc-relative LBA. - * @return span pointer, or nullptr when lba is past the disc end + * Helper: find the span containing a disc-relative LBA. + * @return span pointer, or nullptr when lba is past the disc end */ - const BinSpan* SpanForLBA(uint32_t lba) const; - - bool LoadSBICompanion(const std::string& image_path); + const BinSpan* SpanForLBA(uint32_t lba) const; + + bool LoadSBICompanion(const std::string& image_path); bool is_open_; std::string volume_id_; std::string bin_path_; // first (data) segment; kept for callers RootDirectoryInfo root_dir_; - std::vector tracks_; // from the .cue TOC; >=1 entry after Open() - std::vector segments_; // cue FILE entries in disc order; >=1 after Open() - std::vector spans_; // disc layout over segments_ + cue gaps - uint32_t disc_sector_count_ = 0; // BIN/CUE: end of the last span - std::unique_ptr chd_; // present only for a directly mounted CHD - std::unordered_map> subq_replacements_; -}; + std::vector tracks_; // from the .cue TOC; >=1 entry after Open() + std::vector segments_; // cue FILE entries in disc order; >=1 after Open() + std::vector spans_; // disc layout over segments_ + cue gaps + uint32_t disc_sector_count_ = 0; // BIN/CUE: end of the last span + std::unique_ptr chd_; // present only for a directly mounted CHD + std::unordered_map> subq_replacements_; +}; } // namespace PS1 diff --git a/runtime/src/cue_sheet.cpp b/runtime/src/cue_sheet.cpp index 340608a4..82dedc35 100644 --- a/runtime/src/cue_sheet.cpp +++ b/runtime/src/cue_sheet.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -125,6 +126,7 @@ CueSheet parse_cue_sheet(const fs::path& cue_path) { std::string line; int cur_track_num = -1; bool cur_track_audio = false; + uint32_t cur_sector_size = 0; uint32_t cur_index00 = 0; bool cur_has_index00 = false; uint32_t cur_pregap = 0; @@ -162,8 +164,19 @@ CueSheet parse_cue_sheet(const fs::path& cue_path) { int tn = 0; char type_buf[32] = {0}; if (std::sscanf(upper.c_str(), " TRACK %d %31s", &tn, type_buf) == 2) { + const std::string type(type_buf); cur_track_num = tn; - cur_track_audio = (std::string(type_buf).find("AUDIO") != std::string::npos); + cur_track_audio = (type.find("AUDIO") != std::string::npos); + // "MODE2/2352" etc. name the stored sector size; AUDIO is always + // 2352. Anything else (CDG, malformed) leaves it unknown. + cur_sector_size = 0; + const size_t slash = type.find('/'); + if (cur_track_audio) { + cur_sector_size = 2352; + } else if (slash != std::string::npos) { + const unsigned long n = std::strtoul(type.c_str() + slash + 1, nullptr, 10); + if (n == 2048 || n == 2336 || n == 2352) cur_sector_size = (uint32_t)n; + } cur_index00 = 0; cur_has_index00 = false; cur_pregap = 0; @@ -200,6 +213,7 @@ CueSheet parse_cue_sheet(const fs::path& cue_path) { t.index00 = cur_index00; t.has_index00 = cur_has_index00; t.pregap = cur_pregap; + t.sector_size = cur_sector_size; sheet.tracks.push_back(t); last_pushed_track = cur_track_num; cur_track_num = -1; diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index af866320..5c6c5cfb 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -7,19 +7,19 @@ #include #include #include - -namespace PS1 { - -// PS1 CD-ROM sector size (Mode 2, Form 1 user data) -constexpr size_t SECTOR_SIZE = 2048; - -// Full sector size including headers/subchannel (2352 bytes for raw BIN files) -constexpr size_t RAW_SECTOR_SIZE = 2352; - -// Offset to user data in raw sector (Mode 2, Form 1) -constexpr size_t RAW_DATA_OFFSET = 24; - -// Primary Volume Descriptor location + +namespace PS1 { + +// PS1 CD-ROM sector size (Mode 2, Form 1 user data) +constexpr size_t SECTOR_SIZE = 2048; + +// Full sector size including headers/subchannel (2352 bytes for raw BIN files) +constexpr size_t RAW_SECTOR_SIZE = 2352; + +// Offset to user data in raw sector (Mode 2, Form 1) +constexpr size_t RAW_DATA_OFFSET = 24; + +// Primary Volume Descriptor location constexpr uint32_t PVD_SECTOR = 16; constexpr uint32_t CD_FRAMES_PER_SECOND = 75; @@ -30,6 +30,49 @@ static bool valid_bcd(uint8_t value) { return (value & 0x0f) <= 9 && (value >> 4) <= 9; } +// Sector size the cue's TRACK modes declare for one FILE: 0 when no track of +// that file names a size, UINT32_MAX when its tracks disagree. +static uint32_t cue_declared_sector_size( + const std::vector& tracks, size_t file_index) { + uint32_t declared = 0; + for (const PSXRecompV4::CueTrackRef& t : tracks) { + if (t.file_index != file_index || t.sector_size == 0) continue; + if (declared != 0 && declared != t.sector_size) + return std::numeric_limits::max(); + declared = t.sector_size; + } + return declared; +} + +// Raw (2352) vs cooked (2048) for one stored file. The cue's TRACK mode is +// the authority. File length is not evidence: a raw dump can carry a partial +// trailing sector and still be a multiple of 2048, which a length test mounts +// as a cooked ISO so every read lands mid-sector (LBA 16 reads back zeros and +// the BIOS gives up with SystemError B/906). Without a cue mode, a raw sector +// 0 starts with the 12-byte CD sync pattern and an ISO's system area does not; +// length only decides when sector 0 is unreadable. Returns false for layouts +// this reader cannot mount (conflicting modes, MODE2/2336). +static bool stored_file_is_raw(std::ifstream& file, uint64_t size, + uint32_t declared, bool* raw) { + if (declared == std::numeric_limits::max() || declared == 2336) + return false; + if (declared != 0) { + *raw = declared == RAW_SECTOR_SIZE; + return true; + } + static const uint8_t kSync[12] = {0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00}; + uint8_t head[sizeof(kSync)] = {}; + file.clear(); + file.seekg(0, std::ios::beg); + file.read(reinterpret_cast(head), sizeof(head)); + const bool have_head = file.gcount() == (std::streamsize)sizeof(head); + file.clear(); + *raw = have_head ? std::memcmp(head, kSync, sizeof(kSync)) == 0 + : (size % RAW_SECTOR_SIZE) == 0; + return true; +} + static uint32_t bcd_to_binary(uint8_t value) { return (value >> 4) * 10u + (value & 0x0f); } @@ -143,20 +186,20 @@ static bool read_chd_raw(CHDState& chd, uint32_t lba, uint8_t* buffer, } ISOReader::ISOReader() - : is_open_(false) { - root_dir_.lba = 0; - root_dir_.size = 0; -} - -ISOReader::~ISOReader() { - Close(); -} - + : is_open_(false) { + root_dir_.lba = 0; + root_dir_.size = 0; +} + +ISOReader::~ISOReader() { + Close(); +} + bool ISOReader::Open(const std::string& filename) { - // Close any previously opened image (also resets tracks/segments) - Close(); - - // Check if file exists + // Close any previously opened image (also resets tracks/segments) + Close(); + + // Check if file exists if (!std::filesystem::exists(filename)) { return false; } @@ -270,69 +313,75 @@ bool ISOReader::Open(const std::string& filename) { return true; } - // Ordered list of BINARY files backing the disc. A bare .bin/.iso is a - // single-entry list; a .cue contributes one entry per FILE line (redump - // multi-track dumps ship one file per track). - std::vector bin_files; - - // Tracks parsed from the cue. INDEX times in a cue are relative to the - // OWNING FILE, so remember which file each track belongs to and convert - // to disc-relative LBAs once the segment table (with each file's first - // disc sector) is built below. - using PendingTrack = PSXRecompV4::CueTrackRef; - std::vector pending_tracks; - - if (PSXRecompV4::path_has_extension_ci(std::filesystem::path(filename), ".cue")) { - // Parse the .cue via the shared parser: resolve every FILE, AND build - // the track TOC from the TRACK/INDEX lines. Single-file cues keep - // their historical behavior; multi-file cues (data track + CD-DA audio - // tracks in separate .bin files) map each file to a contiguous run of - // disc sectors, concatenated in cue order. - const PSXRecompV4::CueSheet sheet = - PSXRecompV4::parse_cue_sheet(std::filesystem::path(filename)); - - // A cue we cannot read, one that names no FILE, or one whose payload is - // not raw BINARY (WAVE/MP3 have no fixed sector geometry — mis-mapping - // the TOC would be worse than refusing the image) has nothing to mount. - if (!sheet.opened || sheet.files.empty() || sheet.has_non_binary_file()) { - return false; - } - - bin_files.reserve(sheet.files.size()); - for (const PSXRecompV4::CueFileRef& f : sheet.files) { - bin_files.push_back(f.path.string()); - } - pending_tracks = sheet.tracks; - } else { - bin_files.push_back(filename); - } - - // Build the segment table: open every file and lay it out at the next - // disc-relative sector. Every file must open — a multi-file dump with a - // missing track file would otherwise silently read the wrong sectors. - for (const std::string& bin_name : bin_files) { - BinSegment seg; - seg.path = bin_name; - seg.file.open(bin_name, std::ios::binary); - if (!seg.file.is_open()) { - Close(); - return false; - } - seg.file.seekg(0, std::ios::end); - const std::streampos file_size = seg.file.tellg(); - seg.file.clear(); - if (file_size <= 0) { - Close(); - return false; - } - const uint64_t size = static_cast(file_size); - seg.raw = (size % RAW_SECTOR_SIZE) == 0; - seg.sector_count = static_cast(size / (seg.raw ? RAW_SECTOR_SIZE - : SECTOR_SIZE)); + // Ordered list of BINARY files backing the disc. A bare .bin/.iso is a + // single-entry list; a .cue contributes one entry per FILE line (redump + // multi-track dumps ship one file per track). + std::vector bin_files; + + // Tracks parsed from the cue. INDEX times in a cue are relative to the + // OWNING FILE, so remember which file each track belongs to and convert + // to disc-relative LBAs once the segment table (with each file's first + // disc sector) is built below. + using PendingTrack = PSXRecompV4::CueTrackRef; + std::vector pending_tracks; + + if (PSXRecompV4::path_has_extension_ci(std::filesystem::path(filename), ".cue")) { + // Parse the .cue via the shared parser: resolve every FILE, AND build + // the track TOC from the TRACK/INDEX lines. Single-file cues keep + // their historical behavior; multi-file cues (data track + CD-DA audio + // tracks in separate .bin files) map each file to a contiguous run of + // disc sectors, concatenated in cue order. + const PSXRecompV4::CueSheet sheet = + PSXRecompV4::parse_cue_sheet(std::filesystem::path(filename)); + + // A cue we cannot read, one that names no FILE, or one whose payload is + // not raw BINARY (WAVE/MP3 have no fixed sector geometry — mis-mapping + // the TOC would be worse than refusing the image) has nothing to mount. + if (!sheet.opened || sheet.files.empty() || sheet.has_non_binary_file()) { + return false; + } + + bin_files.reserve(sheet.files.size()); + for (const PSXRecompV4::CueFileRef& f : sheet.files) { + bin_files.push_back(f.path.string()); + } + pending_tracks = sheet.tracks; + } else { + bin_files.push_back(filename); + } + + // Build the segment table: open every file and lay it out at the next + // disc-relative sector. Every file must open — a multi-file dump with a + // missing track file would otherwise silently read the wrong sectors. + for (const std::string& bin_name : bin_files) { + BinSegment seg; + seg.path = bin_name; + seg.file.open(bin_name, std::ios::binary); + if (!seg.file.is_open()) { + Close(); + return false; + } + seg.file.seekg(0, std::ios::end); + const std::streampos file_size = seg.file.tellg(); + seg.file.clear(); + if (file_size <= 0) { + Close(); + return false; + } + const uint64_t size = static_cast(file_size); + if (!stored_file_is_raw(seg.file, size, + cue_declared_sector_size(pending_tracks, segments_.size()), + &seg.raw)) { + Close(); + return false; + } + // Whole sectors only; a partial trailing sector is not addressable. + seg.sector_count = static_cast(size / (seg.raw ? RAW_SECTOR_SIZE + : SECTOR_SIZE)); seg.start_lba = 0; // assigned by the layout pass below - segments_.push_back(std::move(seg)); - } - + segments_.push_back(std::move(seg)); + } + // Lay the files out on the disc and convert the pending cue tracks to // disc-relative LBAs. INDEX times are file-relative; a cue PREGAP (before // a track's INDEX 00/01) or POSTGAP (after its data) is a run of sectors @@ -392,29 +441,29 @@ bool ISOReader::Open(const std::string& filename) { } } push_stored(si, cursor, n); - } + } disc_sector_count_ = disc; - - // Synthesize a single data track for a bare .bin/.iso or a .cue with no - // parseable TRACK entries, so TrackCount() is always >= 1. - if (tracks_.empty()) { - CDTrack t; - t.number = 1; t.is_audio = false; t.start_lba = 0; t.pregap_lba = 0; - tracks_.push_back(t); - } - - // Store the resolved data-track path for callers - bin_path_ = segments_.front().path; - - is_open_ = true; - - // Parse the volume descriptor to extract filesystem metadata when - // present. Runtime CD-ROM access only needs sector reads, so keep the - // image mounted even if an ISO9660 header is missing or nonstandard. + + // Synthesize a single data track for a bare .bin/.iso or a .cue with no + // parseable TRACK entries, so TrackCount() is always >= 1. + if (tracks_.empty()) { + CDTrack t; + t.number = 1; t.is_audio = false; t.start_lba = 0; t.pregap_lba = 0; + tracks_.push_back(t); + } + + // Store the resolved data-track path for callers + bin_path_ = segments_.front().path; + + is_open_ = true; + + // Parse the volume descriptor to extract filesystem metadata when + // present. Runtime CD-ROM access only needs sector reads, so keep the + // image mounted even if an ISO9660 header is missing or nonstandard. if (!ParseVolumeDescriptor()) { - volume_id_.clear(); - root_dir_.lba = 0; - root_dir_.size = 0; + volume_id_.clear(); + root_dir_.lba = 0; + root_dir_.size = 0; } if (!LoadSBICompanion(filename)) { @@ -424,24 +473,43 @@ bool ISOReader::Open(const std::string& filename) { return true; } - + +bool ISOReader::FirstFileIsRaw(const std::string& path, bool* raw) { + if (!raw) return false; + std::string stored = path; + uint32_t declared = 0; + if (PSXRecompV4::path_has_extension_ci(std::filesystem::path(path), ".cue")) { + const PSXRecompV4::CueSheet sheet = + PSXRecompV4::parse_cue_sheet(std::filesystem::path(path)); + if (!sheet.opened || sheet.files.empty()) return false; + stored = sheet.files.front().path.string(); + declared = cue_declared_sector_size(sheet.tracks, 0); + } + std::ifstream file(stored, std::ios::binary); + if (!file.is_open()) return false; + file.seekg(0, std::ios::end); + const std::streampos size = file.tellg(); + if (size <= 0) return false; + return stored_file_is_raw(file, static_cast(size), declared, raw); +} + void ISOReader::Close() { chd_.reset(); - for (BinSegment& seg : segments_) { - if (seg.file.is_open()) { - seg.file.close(); - } - } - segments_.clear(); + for (BinSegment& seg : segments_) { + if (seg.file.is_open()) { + seg.file.close(); + } + } + segments_.clear(); spans_.clear(); disc_sector_count_ = 0; tracks_.clear(); subq_replacements_.clear(); - bin_path_.clear(); - volume_id_.clear(); - root_dir_.lba = 0; - root_dir_.size = 0; - is_open_ = false; + bin_path_.clear(); + volume_id_.clear(); + root_dir_.lba = 0; + root_dir_.size = 0; + is_open_ = false; } bool ISOReader::LoadSBICompanion(const std::string& image_path) { @@ -516,23 +584,23 @@ bool ISOReader::ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) cons *valid = true; return true; } - + const BinSpan* ISOReader::SpanForLBA(uint32_t lba) const { // Linear scan: images carry a handful of spans (one per track file, plus // one per cue gap). for (const BinSpan& span : spans_) { if (lba >= span.disc_start && lba - span.disc_start < span.sector_count) { return &span; - } - } - return nullptr; -} - + } + } + return nullptr; +} + bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { - if (!is_open_ || !buffer) { - return false; - } - + if (!is_open_ || !buffer) { + return false; + } + if (chd_) { uint8_t raw[RAW_SECTOR_SIZE]; CHDTrackMode mode; @@ -546,8 +614,8 @@ bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { const BinSpan* span = SpanForLBA(lba); if (!span) { - return false; - } + return false; + } if (span->segment < 0) { // Cue PREGAP/POSTGAP: not stored in the dump; reads back as zero. std::memset(buffer, 0, SECTOR_SIZE); @@ -555,55 +623,55 @@ bool ISOReader::ReadSector(uint32_t lba, uint8_t* buffer) { } BinSegment* seg = &segments_[(size_t)span->segment]; const uint32_t local_lba = span->file_sector + (lba - span->disc_start); - - // Clear any error flags from a previous failed read - seg->file.clear(); - - if (seg->raw) { - // Raw BIN format - read full sector, extract user data - std::streampos offset = - static_cast(local_lba) * RAW_SECTOR_SIZE + RAW_DATA_OFFSET; - seg->file.seekg(offset, std::ios::beg); - - if (!seg->file.good()) { - seg->file.clear(); - return false; - } - - seg->file.read(reinterpret_cast(buffer), SECTOR_SIZE); - std::streamsize bytes_read = seg->file.gcount(); - bool success = (bytes_read == SECTOR_SIZE); - seg->file.clear(); - return success; - } else { - // ISO format - sectors are already 2048 bytes - std::streampos offset = static_cast(local_lba) * SECTOR_SIZE; - seg->file.seekg(offset, std::ios::beg); - - if (!seg->file.good()) { - seg->file.clear(); - return false; - } - - seg->file.read(reinterpret_cast(buffer), SECTOR_SIZE); - std::streamsize bytes_read = seg->file.gcount(); - bool success = (bytes_read == SECTOR_SIZE); - seg->file.clear(); - return success; - } -} - + + // Clear any error flags from a previous failed read + seg->file.clear(); + + if (seg->raw) { + // Raw BIN format - read full sector, extract user data + std::streampos offset = + static_cast(local_lba) * RAW_SECTOR_SIZE + RAW_DATA_OFFSET; + seg->file.seekg(offset, std::ios::beg); + + if (!seg->file.good()) { + seg->file.clear(); + return false; + } + + seg->file.read(reinterpret_cast(buffer), SECTOR_SIZE); + std::streamsize bytes_read = seg->file.gcount(); + bool success = (bytes_read == SECTOR_SIZE); + seg->file.clear(); + return success; + } else { + // ISO format - sectors are already 2048 bytes + std::streampos offset = static_cast(local_lba) * SECTOR_SIZE; + seg->file.seekg(offset, std::ios::beg); + + if (!seg->file.good()) { + seg->file.clear(); + return false; + } + + seg->file.read(reinterpret_cast(buffer), SECTOR_SIZE); + std::streamsize bytes_read = seg->file.gcount(); + bool success = (bytes_read == SECTOR_SIZE); + seg->file.clear(); + return success; + } +} + bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { - if (!is_open_ || !buffer) { - return false; - } - + if (!is_open_ || !buffer) { + return false; + } + if (chd_) return read_chd_raw(*chd_, lba, buffer, nullptr); const BinSpan* span = SpanForLBA(lba); if (!span) { - return false; - } + return false; + } if (span->segment < 0) { // Cue PREGAP/POSTGAP: not stored in the dump; digital silence, the // same as the CHD path's virtual pregap. @@ -615,352 +683,352 @@ bool ISOReader::ReadRawSector(uint32_t lba, uint8_t* buffer) { return false; } const uint32_t local_lba = span->file_sector + (lba - span->disc_start); - - seg->file.clear(); - std::streampos offset = static_cast(local_lba) * RAW_SECTOR_SIZE; - seg->file.seekg(offset, std::ios::beg); - if (!seg->file.good()) { - seg->file.clear(); - return false; - } - - seg->file.read(reinterpret_cast(buffer), RAW_SECTOR_SIZE); - std::streamsize bytes_read = seg->file.gcount(); - bool success = (bytes_read == RAW_SECTOR_SIZE); - seg->file.clear(); - return success; -} - -bool ISOReader::IsOpen() const { - return is_open_; -} - -std::string ISOReader::GetVolumeID() const { - return volume_id_; -} - -std::string ISOReader::GetBinPath() const { - return bin_path_; -} - + + seg->file.clear(); + std::streampos offset = static_cast(local_lba) * RAW_SECTOR_SIZE; + seg->file.seekg(offset, std::ios::beg); + if (!seg->file.good()) { + seg->file.clear(); + return false; + } + + seg->file.read(reinterpret_cast(buffer), RAW_SECTOR_SIZE); + std::streamsize bytes_read = seg->file.gcount(); + bool success = (bytes_read == RAW_SECTOR_SIZE); + seg->file.clear(); + return success; +} + +bool ISOReader::IsOpen() const { + return is_open_; +} + +std::string ISOReader::GetVolumeID() const { + return volume_id_; +} + +std::string ISOReader::GetBinPath() const { + return bin_path_; +} + uint32_t ISOReader::GetSectorCount() { if (!is_open_) { return 0; } if (chd_) return chd_->disc_sector_count; return disc_sector_count_; -} - -int ISOReader::TrackCount() const { - return static_cast(tracks_.size()); -} - -uint32_t ISOReader::TrackStartLBA(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.start_lba; - } - return 0; -} - -uint32_t ISOReader::TrackPregapLBA(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.pregap_lba; - } - return 0; -} - -bool ISOReader::TrackIsAudio(int track) const { - for (const auto& t : tracks_) { - if (t.number == track) return t.is_audio; - } - return false; -} - -RootDirectoryInfo ISOReader::GetRootDirectory() const { - return root_dir_; -} - -uint32_t ISOReader::Read733(const uint8_t* data) const { - // Read little-endian half of both-endian 32-bit value - return data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); -} - -bool ISOReader::ParseVolumeDescriptor() { - // Read Primary Volume Descriptor from sector 16 - uint8_t pvd[SECTOR_SIZE]; - if (!ReadSector(PVD_SECTOR, pvd)) { - return false; - } - - // Verify ISO9660 signature: offset 1 should contain "CD001" - if (pvd[0] != 0x01 || std::memcmp(&pvd[1], "CD001", 5) != 0) { - return false; // Not a valid ISO9660 disc - } - - // Extract volume ID (offset 40, 32 bytes, space-padded ASCII) - volume_id_.clear(); - for (int i = 0; i < 32; i++) { - char c = pvd[40 + i]; - if (c != ' ' && c != '\0') { - volume_id_ += c; - } - } - - // Extract root directory record (offset 156, 34 bytes) - const uint8_t* root_record = &pvd[156]; - - // Root directory LBA is at offset 2 within the directory record (both-endian 32-bit) - root_dir_.lba = Read733(&root_record[2]); - - // Root directory size is at offset 10 within the directory record (both-endian 32-bit) - root_dir_.size = Read733(&root_record[10]); - - return true; -} - -bool ISOReader::ParseDirectoryRecord(const uint8_t* data, ISOFileEntry& entry) const { - // Check record length (offset 0) - uint8_t record_len = data[0]; - if (record_len == 0 || record_len < 33) { - return false; // Invalid or padding record - } - - // Extract LBA (offset 2, both-endian 32-bit) - entry.lba = Read733(&data[2]); - - // Extract file size (offset 10, both-endian 32-bit) - entry.size = Read733(&data[10]); - - // Extract file flags (offset 25) - uint8_t flags = data[25]; - entry.is_directory = (flags & 0x02) != 0; - - // Extract filename length (offset 32) - uint8_t name_len = data[32]; - if (name_len == 0) { - return false; // Invalid record - } - - // Extract filename (offset 33) - const char* name_ptr = reinterpret_cast(&data[33]); - - // Handle special directory entries - if (name_len == 1 && name_ptr[0] == '\x00') { - entry.name = "."; // Current directory - return true; - } - if (name_len == 1 && name_ptr[0] == '\x01') { - entry.name = ".."; // Parent directory - return true; - } - - // Parse regular filename, strip version suffix (";1") - entry.name.clear(); - for (uint8_t i = 0; i < name_len; i++) { - char c = name_ptr[i]; - if (c == ';') { - break; // Stop at version separator - } - entry.name += c; - } - - return true; -} - -std::vector ISOReader::ListFilesByLBA(uint32_t lba, uint32_t dir_size) { - std::vector results; - - if (!is_open_ || lba == 0 || dir_size == 0) { - return results; - } - - // Calculate number of sectors needed for directory data - uint32_t num_sectors = (dir_size + 2047) / 2048; - - // Allocate buffer for directory data - std::vector dir_data(num_sectors * 2048, 0); - - // Read all directory sectors - for (uint32_t i = 0; i < num_sectors; i++) { - if (!ReadSector(lba + i, &dir_data[i * 2048])) { - return results; // Error reading sector - } - } - - // Parse directory records - uint32_t offset = 0; - while (offset < dir_size) { - uint8_t record_len = dir_data[offset]; - - if (record_len == 0) { - // Skip to next sector boundary - uint32_t sector_offset = offset % 2048; - if (sector_offset != 0) { - offset += (2048 - sector_offset); - continue; - } - break; - } - - if (offset + record_len > dir_data.size()) break; - - ISOFileEntry entry; - if (ParseDirectoryRecord(&dir_data[offset], entry)) { - if (entry.name != "." && entry.name != "..") { - results.push_back(entry); - } - } - - offset += record_len; - } - - return results; -} - -std::vector ISOReader::ListFiles(const std::string& path) { - std::vector results; - - // Check if file is open - if (!is_open_) { - return results; - } - - if (path.empty()) { - // List root directory - RootDirectoryInfo root = GetRootDirectory(); - return ListFilesByLBA(root.lba, root.size); - } - - // Non-empty path: navigate to that subdirectory within the root - // Find the matching directory entry in root - RootDirectoryInfo root = GetRootDirectory(); - std::vector root_entries = ListFilesByLBA(root.lba, root.size); - - std::string path_upper = path; - std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - for (const auto& e : root_entries) { - if (!e.is_directory) continue; - std::string name_upper = e.name; - std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - if (name_upper == path_upper) { - // Found the subdirectory — list its contents - return ListFilesByLBA(e.lba, e.size > 0 ? e.size : 2048); - } - } - - return results; // Directory not found -} - -bool ISOReader::FindFile(const std::string& path, ISOFileEntry& entry) { - // Check if file is open - if (!is_open_) { - return false; - } - - // Check if path contains a directory separator - size_t sep = path.find('/'); - if (sep == std::string::npos) { - sep = path.find('\\'); - } - - if (sep != std::string::npos) { - // Subdirectory path: "DIR/FILE" or "DIR\FILE" - std::string dir_name = path.substr(0, sep); - std::string file_name = path.substr(sep + 1); - - // List the subdirectory - std::vector sub_files = ListFiles(dir_name); - - std::string file_upper = file_name; - std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - for (const auto& f : sub_files) { - std::string name_upper = f.name; - std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - if (name_upper == file_upper) { - entry = f; - return true; - } - } - return false; - } - - // Root-level file: search root directory - std::vector files = ListFiles(""); - - // Search for matching filename (case-insensitive comparison) - for (const auto& file : files) { - std::string file_upper = file.name; - std::string path_upper = path; - - std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), - [](unsigned char c) { return std::toupper(c); }); - - if (file_upper == path_upper) { - entry = file; - return true; - } - } - - // File not found - return false; -} - -size_t ISOReader::ReadFile(const std::string& path, uint8_t* buffer, size_t max_size) { - // Validate buffer pointer - if (!buffer) { - return 0; - } - - // Find the file - ISOFileEntry entry; - if (!FindFile(path, entry)) { - return 0; // File not found - } - - // Calculate how many bytes to read (min of file size and max_size) - size_t bytes_to_read = std::min(static_cast(entry.size), max_size); - - // Calculate number of sectors to read - uint32_t sectors_to_read = (bytes_to_read + SECTOR_SIZE - 1) / SECTOR_SIZE; - - // Read sectors sequentially - size_t bytes_read = 0; - for (uint32_t i = 0; i < sectors_to_read; i++) { - // Calculate how many bytes to read from this sector - size_t bytes_remaining = bytes_to_read - bytes_read; - size_t sector_bytes = std::min(bytes_remaining, SECTOR_SIZE); - - // Read sector into temporary buffer - uint8_t sector_buffer[SECTOR_SIZE]; - if (!ReadSector(entry.lba + i, sector_buffer)) { - return bytes_read; // Error - return what we've read so far - } - - // Copy data to output buffer - std::memcpy(buffer + bytes_read, sector_buffer, sector_bytes); - bytes_read += sector_bytes; - } - - return bytes_read; -} - -size_t ISOReader::GetFileSize(const std::string& path) { - // Find the file - ISOFileEntry entry; - if (!FindFile(path, entry)) { - return 0; // File not found - } - - // Return file size - return entry.size; -} - -} // namespace PS1 +} + +int ISOReader::TrackCount() const { + return static_cast(tracks_.size()); +} + +uint32_t ISOReader::TrackStartLBA(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.start_lba; + } + return 0; +} + +uint32_t ISOReader::TrackPregapLBA(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.pregap_lba; + } + return 0; +} + +bool ISOReader::TrackIsAudio(int track) const { + for (const auto& t : tracks_) { + if (t.number == track) return t.is_audio; + } + return false; +} + +RootDirectoryInfo ISOReader::GetRootDirectory() const { + return root_dir_; +} + +uint32_t ISOReader::Read733(const uint8_t* data) const { + // Read little-endian half of both-endian 32-bit value + return data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); +} + +bool ISOReader::ParseVolumeDescriptor() { + // Read Primary Volume Descriptor from sector 16 + uint8_t pvd[SECTOR_SIZE]; + if (!ReadSector(PVD_SECTOR, pvd)) { + return false; + } + + // Verify ISO9660 signature: offset 1 should contain "CD001" + if (pvd[0] != 0x01 || std::memcmp(&pvd[1], "CD001", 5) != 0) { + return false; // Not a valid ISO9660 disc + } + + // Extract volume ID (offset 40, 32 bytes, space-padded ASCII) + volume_id_.clear(); + for (int i = 0; i < 32; i++) { + char c = pvd[40 + i]; + if (c != ' ' && c != '\0') { + volume_id_ += c; + } + } + + // Extract root directory record (offset 156, 34 bytes) + const uint8_t* root_record = &pvd[156]; + + // Root directory LBA is at offset 2 within the directory record (both-endian 32-bit) + root_dir_.lba = Read733(&root_record[2]); + + // Root directory size is at offset 10 within the directory record (both-endian 32-bit) + root_dir_.size = Read733(&root_record[10]); + + return true; +} + +bool ISOReader::ParseDirectoryRecord(const uint8_t* data, ISOFileEntry& entry) const { + // Check record length (offset 0) + uint8_t record_len = data[0]; + if (record_len == 0 || record_len < 33) { + return false; // Invalid or padding record + } + + // Extract LBA (offset 2, both-endian 32-bit) + entry.lba = Read733(&data[2]); + + // Extract file size (offset 10, both-endian 32-bit) + entry.size = Read733(&data[10]); + + // Extract file flags (offset 25) + uint8_t flags = data[25]; + entry.is_directory = (flags & 0x02) != 0; + + // Extract filename length (offset 32) + uint8_t name_len = data[32]; + if (name_len == 0) { + return false; // Invalid record + } + + // Extract filename (offset 33) + const char* name_ptr = reinterpret_cast(&data[33]); + + // Handle special directory entries + if (name_len == 1 && name_ptr[0] == '\x00') { + entry.name = "."; // Current directory + return true; + } + if (name_len == 1 && name_ptr[0] == '\x01') { + entry.name = ".."; // Parent directory + return true; + } + + // Parse regular filename, strip version suffix (";1") + entry.name.clear(); + for (uint8_t i = 0; i < name_len; i++) { + char c = name_ptr[i]; + if (c == ';') { + break; // Stop at version separator + } + entry.name += c; + } + + return true; +} + +std::vector ISOReader::ListFilesByLBA(uint32_t lba, uint32_t dir_size) { + std::vector results; + + if (!is_open_ || lba == 0 || dir_size == 0) { + return results; + } + + // Calculate number of sectors needed for directory data + uint32_t num_sectors = (dir_size + 2047) / 2048; + + // Allocate buffer for directory data + std::vector dir_data(num_sectors * 2048, 0); + + // Read all directory sectors + for (uint32_t i = 0; i < num_sectors; i++) { + if (!ReadSector(lba + i, &dir_data[i * 2048])) { + return results; // Error reading sector + } + } + + // Parse directory records + uint32_t offset = 0; + while (offset < dir_size) { + uint8_t record_len = dir_data[offset]; + + if (record_len == 0) { + // Skip to next sector boundary + uint32_t sector_offset = offset % 2048; + if (sector_offset != 0) { + offset += (2048 - sector_offset); + continue; + } + break; + } + + if (offset + record_len > dir_data.size()) break; + + ISOFileEntry entry; + if (ParseDirectoryRecord(&dir_data[offset], entry)) { + if (entry.name != "." && entry.name != "..") { + results.push_back(entry); + } + } + + offset += record_len; + } + + return results; +} + +std::vector ISOReader::ListFiles(const std::string& path) { + std::vector results; + + // Check if file is open + if (!is_open_) { + return results; + } + + if (path.empty()) { + // List root directory + RootDirectoryInfo root = GetRootDirectory(); + return ListFilesByLBA(root.lba, root.size); + } + + // Non-empty path: navigate to that subdirectory within the root + // Find the matching directory entry in root + RootDirectoryInfo root = GetRootDirectory(); + std::vector root_entries = ListFilesByLBA(root.lba, root.size); + + std::string path_upper = path; + std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + for (const auto& e : root_entries) { + if (!e.is_directory) continue; + std::string name_upper = e.name; + std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + if (name_upper == path_upper) { + // Found the subdirectory — list its contents + return ListFilesByLBA(e.lba, e.size > 0 ? e.size : 2048); + } + } + + return results; // Directory not found +} + +bool ISOReader::FindFile(const std::string& path, ISOFileEntry& entry) { + // Check if file is open + if (!is_open_) { + return false; + } + + // Check if path contains a directory separator + size_t sep = path.find('/'); + if (sep == std::string::npos) { + sep = path.find('\\'); + } + + if (sep != std::string::npos) { + // Subdirectory path: "DIR/FILE" or "DIR\FILE" + std::string dir_name = path.substr(0, sep); + std::string file_name = path.substr(sep + 1); + + // List the subdirectory + std::vector sub_files = ListFiles(dir_name); + + std::string file_upper = file_name; + std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + for (const auto& f : sub_files) { + std::string name_upper = f.name; + std::transform(name_upper.begin(), name_upper.end(), name_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + if (name_upper == file_upper) { + entry = f; + return true; + } + } + return false; + } + + // Root-level file: search root directory + std::vector files = ListFiles(""); + + // Search for matching filename (case-insensitive comparison) + for (const auto& file : files) { + std::string file_upper = file.name; + std::string path_upper = path; + + std::transform(file_upper.begin(), file_upper.end(), file_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + std::transform(path_upper.begin(), path_upper.end(), path_upper.begin(), + [](unsigned char c) { return std::toupper(c); }); + + if (file_upper == path_upper) { + entry = file; + return true; + } + } + + // File not found + return false; +} + +size_t ISOReader::ReadFile(const std::string& path, uint8_t* buffer, size_t max_size) { + // Validate buffer pointer + if (!buffer) { + return 0; + } + + // Find the file + ISOFileEntry entry; + if (!FindFile(path, entry)) { + return 0; // File not found + } + + // Calculate how many bytes to read (min of file size and max_size) + size_t bytes_to_read = std::min(static_cast(entry.size), max_size); + + // Calculate number of sectors to read + uint32_t sectors_to_read = (bytes_to_read + SECTOR_SIZE - 1) / SECTOR_SIZE; + + // Read sectors sequentially + size_t bytes_read = 0; + for (uint32_t i = 0; i < sectors_to_read; i++) { + // Calculate how many bytes to read from this sector + size_t bytes_remaining = bytes_to_read - bytes_read; + size_t sector_bytes = std::min(bytes_remaining, SECTOR_SIZE); + + // Read sector into temporary buffer + uint8_t sector_buffer[SECTOR_SIZE]; + if (!ReadSector(entry.lba + i, sector_buffer)) { + return bytes_read; // Error - return what we've read so far + } + + // Copy data to output buffer + std::memcpy(buffer + bytes_read, sector_buffer, sector_bytes); + bytes_read += sector_bytes; + } + + return bytes_read; +} + +size_t ISOReader::GetFileSize(const std::string& path) { + // Find the file + ISOFileEntry entry; + if (!FindFile(path, entry)) { + return 0; // File not found + } + + // Return file size + return entry.size; +} + +} // namespace PS1 diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 86913b04..343186b9 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -714,8 +714,14 @@ bool sha256_disc_range(const std::filesystem::path& image, std::array bytes{}; size_t remaining = size; uint64_t at = location; - const bool raw_source = file_size > 0 && - ((uint64_t)file_size % 2352u) == 0; + // Same geometry rule as the mount (cue TRACK mode, else sector-0 sync): + // a raw dump whose length happens to be a multiple of 2048 is still raw. + bool raw_source = false; + if (target == ModPatchTarget::DiscUser && + !PS1::ISOReader::FirstFileIsRaw(image.string(), &raw_source)) { + if (error) *error = "cannot determine stock image sector geometry: " + image.string(); + return false; + } while (remaining != 0) { uint64_t physical = at; size_t chunk = remaining; diff --git a/runtime/tests/test_iso_reader_geometry.cpp b/runtime/tests/test_iso_reader_geometry.cpp new file mode 100644 index 00000000..9b4d5995 --- /dev/null +++ b/runtime/tests/test_iso_reader_geometry.cpp @@ -0,0 +1,136 @@ +// Raw (2352) vs cooked (2048) sector geometry. The cue's TRACK mode decides; +// a bare file is raw when sector 0 starts with the CD sync pattern. File +// length is never the evidence: the Legend of Dragoon disc 1 dump is a raw +// MODE2/2352 image with 384 trailing bytes whose length is a multiple of 2048, +// and a length test mounted it as a cooked ISO (PVD read back as zeros, BIOS +// SystemError B/906). + +#include "iso_reader.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +static const uint8_t kSync[12] = {0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00}; + +// Raw MODE2 sectors: sync + header + 8-byte subheader, user data = marker i+1. +static void write_raw(const fs::path& path, uint32_t sectors, size_t trailing) { + std::ofstream out(path, std::ios::binary); + for (uint32_t i = 0; i < sectors; ++i) { + std::vector s(2352, 0); + std::memcpy(s.data(), kSync, sizeof(kSync)); + s[15] = 2; + std::memset(s.data() + 24, (int)(i + 1), 2048); + out.write(reinterpret_cast(s.data()), (std::streamsize)s.size()); + } + std::vector tail(trailing, 0xEE); + out.write(reinterpret_cast(tail.data()), (std::streamsize)tail.size()); +} + +// Cooked sectors; sector 0 is an all-zero system-area sector, others = i+1. +static void write_cooked(const fs::path& path, uint32_t sectors) { + std::ofstream out(path, std::ios::binary); + for (uint32_t i = 0; i < sectors; ++i) { + std::vector s(2048, i == 0 ? 0 : (uint8_t)(i + 1)); + out.write(reinterpret_cast(s.data()), (std::streamsize)s.size()); + } +} + +static void write_text(const fs::path& path, const std::string& text) { + std::ofstream out(path); + out << text; +} + +static void check_markers(const std::string& image, uint32_t sectors, bool first_is_zero) { + PS1::ISOReader r; + CHECK(r.Open(image)); + CHECK(r.GetSectorCount() == sectors); + uint8_t buf[2048]; + for (uint32_t i = 0; i < sectors; ++i) { + CHECK(r.ReadSector(i, buf)); + const uint8_t want = (i == 0 && first_is_zero) ? 0 : (uint8_t)(i + 1); + if (buf[0] != want || buf[2047] != want) { + std::printf(" %s sector %u: got %02x want %02x\n", image.c_str(), i, buf[0], want); + ++g_failures; + } + } + CHECK(!r.ReadSector(sectors, buf)); +} + +int main() { + const fs::path dir = fs::temp_directory_path() / "psxrecomp-iso-geometry-test"; + fs::remove_all(dir); + fs::create_directories(dir); + + // 20 raw sectors + 64 trailing bytes = 47104 = 23 * 2048: a length test + // calls this a cooked ISO. + const fs::path odd = dir / "odd.bin"; + write_raw(odd, 20, 64); + CHECK(fs::file_size(odd) % 2048 == 0); + CHECK(fs::file_size(odd) % 2352 != 0); + + // Cue declares MODE2/2352: raw. + write_text(dir / "odd.cue", "FILE \"odd.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"); + check_markers((dir / "odd.cue").string(), 20, false); + bool raw = false; + CHECK(PS1::ISOReader::FirstFileIsRaw((dir / "odd.cue").string(), &raw) && raw); + + // Bare: sector-0 sync says raw. + check_markers(odd.string(), 20, false); + raw = false; + CHECK(PS1::ISOReader::FirstFileIsRaw(odd.string(), &raw) && raw); + + // A cooked ISO whose length is a multiple of BOTH 2048 and 2352 + // (147 * 2048 = 128 * 2352): no sync at sector 0, so cooked. + const fs::path both = dir / "both.iso"; + write_cooked(both, 147); + CHECK(fs::file_size(both) % 2352 == 0); + check_markers(both.string(), 147, true); + raw = true; + CHECK(PS1::ISOReader::FirstFileIsRaw(both.string(), &raw) && !raw); + + // Cue MODE1/2048 over a cooked file: cooked. + write_text(dir / "both.cue", "FILE \"both.iso\" BINARY\n TRACK 01 MODE1/2048\n INDEX 01 00:00:00\n"); + check_markers((dir / "both.cue").string(), 147, true); + + // Tracks of one FILE disagreeing on sector size: refuse. + write_text(dir / "conflict.cue", + "FILE \"odd.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n" + " TRACK 02 MODE1/2048\n INDEX 01 00:00:10\n"); + { + PS1::ISOReader r; + CHECK(!r.Open((dir / "conflict.cue").string())); + CHECK(!PS1::ISOReader::FirstFileIsRaw((dir / "conflict.cue").string(), &raw)); + } + + // MODE2/2336 has no sync/header; not mountable here: refuse, never misread. + write_text(dir / "m2336.cue", "FILE \"odd.bin\" BINARY\n TRACK 01 MODE2/2336\n INDEX 01 00:00:00\n"); + { + PS1::ISOReader r; + CHECK(!r.Open((dir / "m2336.cue").string())); + } + + fs::remove_all(dir); + if (g_failures) { + std::printf("iso_reader_geometry_test: %d failure(s)\n", g_failures); + return 1; + } + std::printf("iso_reader_geometry_test: PASS\n"); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index a0a6ec1c..977f3fe9 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1165,6 +1165,9 @@ int main(int argc, char** argv) { check(!psx_mod_read_disc_file("S0",nullptr,0,&bytes),"directory rejected"); std::vector raw_iso(24*2352); for(unsigned i=0;i<24;++i) { + /* A raw sector starts with the CD sync pattern; geometry is read from + * it, never from the file length. */ + raw_iso[i*2352]=0; std::fill_n(raw_iso.begin()+i*2352+1,10,(uint8_t)0xFF); raw_iso[i*2352+11]=0; raw_iso[i*2352+15]=2; std::copy_n(iso.begin()+i*2048,2048,raw_iso.begin()+i*2352+24); } From 8ff049557729945883454215e1b0066708c4e394 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 11 Oct 2026 23:47:01 -0700 Subject: [PATCH 07/17] display_ring_get: decode whole display (480i, depth24) from the frame's VRAM snapshot Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/gpu.h | 5 +++ runtime/src/debug_server.c | 92 +++++++++++++++----------------------- runtime/src/gpu.c | 31 +++++++------ 3 files changed, 60 insertions(+), 68 deletions(-) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index b2821fcd..868b8506 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -64,6 +64,11 @@ void gpu_texture_correction_stats(uint64_t *attempts, uint64_t *armed, int gpu_display_is_depth24(void); void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, uint8_t* r, uint8_t* g, uint8_t* b); +/* Same decode against any 1024x512 VRAM image (e.g. a display-ring snapshot) + * instead of live VRAM: 15-bit or depth24 per `di`. */ +void gpu_display_pixel_rgb_in(const uint16_t* image, const GpuDisplayInfo* di, + uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b); uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y); /* Batch equivalent of calling gpu_display_pixel_argb(di, x, y) for x in * [0, count) on a depth24 (FMV) scanline — same output, far less per-pixel diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 27b495ea..3e4b6009 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9925,23 +9925,22 @@ static void handle_get_snapshots(int id, const char *json) * deterministic runs (GL vs software) can each serve the display for the * SAME frame number, giving a pixel-exact renderer-divergence census. * GL reads GPU-side truth via the fbo peek; software reads CPU VRAM (its - * authoritative surface). depth24 scanout frames are tagged and refused - * (15-bit only). */ -#define DISP_RING_CAP 64 /* full-VRAM entries are 1MB each; 32 frames of - * lookback (~0.3-0.5s) for exact-frame forensics */ -#define DISP_RING_MAX_W 640 -#define DISP_RING_MAX_H 256 -/* Full-VRAM aux capture alongside the display: the entire 1024x512 raw VRAM - * as the renderer sees it at this frame (GL: FBO truth; SW: CPU truth), so an - * offline client can micro-cosim ANY prim of the frame — rasterize it from - * the GP0 ring against the exact same-frame texels/CLUTs — and attribute a - * bad pixel to sampling vs content. Streamed texture pages and palette - * cycling make later peeks worthless; only a same-frame capture is evidence. */ + * authoritative surface). Each entry is the full 1024x512 VRAM plus the + * display geometry of that frame, and the PNG is decoded from it with the + * same scanout decode as screenshot_file — so 480-line interlaced displays + * and depth24 (FMV) frames are served whole, not cropped or refused. */ +#define DISP_RING_CAP 64 /* full-VRAM entries are 1MB each; ~1s of + * lookback for exact-frame forensics */ +/* The entire 1024x512 raw VRAM as the renderer sees it at this frame (GL: FBO + * truth; SW and depth24: CPU truth), so an offline client can micro-cosim ANY + * prim of the frame — rasterize it from the GP0 ring against the exact + * same-frame texels/CLUTs — and attribute a bad pixel to sampling vs content. + * Streamed texture pages and palette cycling make later peeks worthless; only + * a same-frame capture is evidence. */ typedef struct { uint32_t frame; - uint16_t w, h; - uint8_t depth24, valid; - uint16_t *px; /* DISP_RING_MAX_W*DISP_RING_MAX_H halfwords */ + uint8_t valid; + GpuDisplayInfo di; /* display geometry/depth at capture */ uint16_t *vram; /* full 1024x512 */ } DispRingEntry; static PSX_BSS DispRingEntry s_disp_ring[DISP_RING_CAP]; @@ -9966,15 +9965,11 @@ static void disp_ring_capture(void) } if (!enabled || s_capture_frozen) return; if (!s_disp_ring_px) { - size_t per = (size_t)DISP_RING_MAX_W * DISP_RING_MAX_H; size_t vram = (size_t)1024 * 512; - s_disp_ring_px = (uint16_t *)malloc(DISP_RING_CAP * (per + vram) * sizeof(uint16_t)); + s_disp_ring_px = (uint16_t *)malloc(DISP_RING_CAP * vram * sizeof(uint16_t)); if (!s_disp_ring_px) return; - for (int i = 0; i < DISP_RING_CAP; i++) { - uint16_t *base = s_disp_ring_px + (size_t)i * (per + vram); - s_disp_ring[i].px = base; - s_disp_ring[i].vram = base + per; - } + for (int i = 0; i < DISP_RING_CAP; i++) + s_disp_ring[i].vram = s_disp_ring_px + (size_t)i * vram; } DispRingEntry *e = &s_disp_ring[(uint32_t)(s_frame_count % DISP_RING_CAP)]; e->frame = (uint32_t)s_frame_count; @@ -9983,30 +9978,15 @@ static void disp_ring_capture(void) gpu_get_display_info(&di); if (di.disabled || di.width == 0 || di.height == 0) return; gpu_timeline_note(GTL_DISP_CAPTURE, 0, di.display_x | (di.display_y << 16)); - uint32_t w = di.width, h = di.height; - if (w > DISP_RING_MAX_W) w = DISP_RING_MAX_W; - if (h > DISP_RING_MAX_H) h = DISP_RING_MAX_H; - e->w = (uint16_t)w; e->h = (uint16_t)h; - e->depth24 = (uint8_t)(di.depth24 ? 1 : 0); - /* GPU-side truth when the GL raster pipeline is live (scissored pack of - * just the display rect + one small readback); CPU VRAM otherwise (the - * software backend's authoritative surface). Runs on the present thread, - * where the GL context is current. */ - /* Deferred: with the render thread on, the readback is recorded at this - * point of the command stream and filled in by the render thread (ring - * readers sync first), instead of a per-frame sync point. */ - int got = 0; - if (di.display_x + w <= 1024 && di.display_y + h <= 512) - got = gl_renderer_fbo_peek_deferred((int)di.display_x, (int)di.display_y, - (int)w, (int)h, e->px); - if (!got) { - for (uint32_t y = 0; y < h; y++) - for (uint32_t x = 0; x < w; x++) - e->px[y * w + x] = - gpu_vram_peek((int)(di.display_x + x), (int)(di.display_y + y)); - } - /* Full-VRAM aux capture (same GL-truth/CPU-truth split as the display). */ - if (!gl_renderer_fbo_peek_deferred(0, 0, 1024, 512, e->vram)) { + e->di = di; + /* GPU-side truth when the GL raster pipeline is live; CPU VRAM otherwise + * (the software backend's authoritative surface). depth24 always reads + * the CPU mirror: packed RGB888 MDEC bytes live there, not in the FBO + * (see screenshot_file). Runs on the present thread, where the GL context + * is current. Deferred: with the render thread on, the readback is + * recorded at this point of the command stream and filled in by the + * render thread (ring readers sync first). */ + if (di.depth24 || !gl_renderer_fbo_peek_deferred(0, 0, 1024, 512, e->vram)) { const uint16_t *v = gpu_get_vram(); memcpy(e->vram, v, (size_t)1024 * 512 * sizeof(uint16_t)); } @@ -10069,16 +10049,17 @@ static void handle_display_ring_get(int id, const char *json) if (!e->valid || e->frame != (uint32_t)f) { send_err(id, "frame not in display ring"); return; } - if (e->depth24) { send_err(id, "frame is 24bpp scanout (unsupported)"); return; } - uint32_t w = e->w, h = e->h; + /* Same clamp and scanout decode as screenshot_file, against this frame's + * VRAM snapshot. */ + uint32_t w = e->di.width; if (w > 640) w = 640; + uint32_t h = e->di.height; if (h > 512) h = 512; uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3); if (!rgb) { send_err(id, "alloc failed"); return; } - for (uint32_t i = 0; i < w * h; i++) { - uint16_t p = e->px[i]; - rgb[i * 3 + 0] = (uint8_t)((p & 0x1F) << 3); - rgb[i * 3 + 1] = (uint8_t)(((p >> 5) & 0x1F) << 3); - rgb[i * 3 + 2] = (uint8_t)(((p >> 10) & 0x1F) << 3); - } + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t *p = rgb + ((size_t)y * w + x) * 3; + gpu_display_pixel_rgb_in(e->vram, &e->di, x, y, &p[0], &p[1], &p[2]); + } FILE *fp = fopen(path, "wb"); if (!fp) { free(rgb); send_err(id, "cannot open file"); return; } int ok = png_write_rgb(fp, rgb, w, h); @@ -10086,7 +10067,8 @@ static void handle_display_ring_get(int id, const char *json) fclose(fp); if (!ok) { send_err(id, "png encode failed"); return; } send_fmt("{\"id\":%d,\"ok\":true,\"frame\":%d,\"path\":\"%s\"," - "\"width\":%u,\"height\":%u}", id, f, path, w, h); + "\"width\":%u,\"height\":%u,\"depth24\":%d}", id, f, path, w, h, + e->di.depth24 ? 1 : 0); } static void handle_screenshot_file(int id, const char *json) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8101a9e8..cab5a093 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -4198,11 +4198,6 @@ const uint16_t* gpu_get_vram(void) { return vram; } -static uint8_t gpu_vram_byte(uint32_t byte_x, uint32_t y) { - uint16_t hw = vram[((y & 511u) * 1024u) + ((byte_x >> 1) & 1023u)]; - return (byte_x & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; -} - /* Depth24: note/query/reset the CPU→VRAM upload span tracked above. Used to * hide trailing RGB columns when a movie blit doesn't fill the full CRTC * width — MotK's Star Wars crawl leaves ~8px of stale VRAM on the right. @@ -4308,8 +4303,9 @@ static void gpu_rgb555_to_rgb888(uint16_t c, uint8_t* r, uint8_t* g, uint8_t* b) *b = (uint8_t)(((c >> 10) & 0x1Fu) << 3); } -void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, - uint8_t* r, uint8_t* g, uint8_t* b) { +void gpu_display_pixel_rgb_in(const uint16_t* image, const GpuDisplayInfo* di, + uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b) { if (di->depth24) { uint32_t byte_x = ((di->display_x & 1023u) * 2u) + x * 3u; uint32_t vy = (di->display_y + y) & 511u; @@ -4320,15 +4316,24 @@ void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, *r = *g = *b = 0; return; } - *r = gpu_vram_byte(byte_x + 0u, vy); - *g = gpu_vram_byte(byte_x + 1u, vy); - *b = gpu_vram_byte(byte_x + 2u, vy); + const uint16_t* row = image + (size_t)vy * 1024u; + uint8_t px[3]; + for (uint32_t i = 0; i < 3u; i++) { + uint16_t hw = row[((byte_x + i) >> 1) & 1023u]; + px[i] = ((byte_x + i) & 1u) ? (uint8_t)(hw >> 8) : (uint8_t)hw; + } + *r = px[0]; *g = px[1]; *b = px[2]; return; } uint32_t vx = (di->display_x + x) & 1023u; uint32_t vy = (di->display_y + y) & 511u; - gpu_rgb555_to_rgb888(vram[vy * 1024u + vx], r, g, b); + gpu_rgb555_to_rgb888(image[vy * 1024u + vx], r, g, b); +} + +void gpu_display_pixel_rgb(const GpuDisplayInfo* di, uint32_t x, uint32_t y, + uint8_t* r, uint8_t* g, uint8_t* b) { + gpu_display_pixel_rgb_in(vram, di, x, y, r, g, b); } uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y) { @@ -4342,11 +4347,11 @@ uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* di, uint32_t x, uint32_t y * output, including the black-fill past the 2048-byte VRAM row), but hoists * the per-row invariants (vy, base_byte_x, the "how many pixels are past the * row edge" test) out of the per-pixel path instead of recomputing them - * `count` times and paying three gpu_vram_byte() calls per pixel. The + * `count` times and paying three byte extractions per pixel. The * per-pixel path was the dominant present-side cost of FMV frames (3x the * VRAM touches of the 16-bit path, done as a function-call chain instead of * a straight-line loop). Still scalar C — no host-endianness assumptions, - * same byte-order shifts as gpu_vram_byte. */ + * same byte-order shifts as gpu_display_pixel_rgb_in. */ void gpu_depth24_present_row(const GpuDisplayInfo* di, uint32_t y, uint32_t* out, uint32_t count) { uint32_t vy = (di->display_y + y) & 511u; From a964f6dc70d4d2b88a441fcf0866ad907c0439d6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:44:02 -0700 Subject: [PATCH 08/17] host_output: no MMIO side effects, CPU copy bus is observational Review 2 (mstan): psx_mod_read_* in a host-output hook now peek RAM / scratchpad / BIOS ROM (psx_peek_word) and read MMIO as 0, so SIO data / stat reads no longer pop RX or age the ACK. The CPUState copy's bus pointers are replaced: reads peek, writes are dropped, so cpu->write_* cannot reach the shared guest. psx_mod_call_guest_uncharged_counted clears *counted_cycles on refusal. netplay_view_mods_test now runs the real sio.c behind the bus: the host-output callback reads SIO_DATA / SIO_STAT through both the mod API and cpu->read_*, and stores through cpu->write_* (RAM and SIO_CTRL); it checks 0 MMIO bus accesses, SIO stat / ctrl / RX unchanged, RAM unchanged. Other tests linking mod_runtime.cpp get a psx_peek_word stub. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 3 +- runtime/src/mod_guest_services.c | 6 +- runtime/src/mod_runtime.cpp | 31 ++++++++- runtime/tests/test_mod_resident.cpp | 2 + runtime/tests/test_mod_runtime.cpp | 2 + runtime/tests/test_netplay_view_mods.cpp | 86 ++++++++++++++++++++++++ runtime/tests/test_pgxp_session.cpp | 2 + 7 files changed, 128 insertions(+), 4 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5e84519f..fa3658fa 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1238,7 +1238,8 @@ if(BUILD_TESTING) src/cue_sheet.cpp src/iso_reader.cpp src/crc32.c - src/psx_sha256.c) + src/psx_sha256.c + src/sio.c) target_include_directories(netplay_view_mods_test PRIVATE include ../recompiler/include diff --git a/runtime/src/mod_guest_services.c b/runtime/src/mod_guest_services.c index d23d216f..a15282bf 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -72,7 +72,11 @@ uint32_t psx_mod_call_guest_uncharged_counted(struct CPUState* cpu, uint32_t function, uint32_t return_address, uint32_t a0, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t budget_cycles, int* charged, uint64_t* counted_cycles) { - if (psx_mod_host_output_scope()) { if (charged) *charged = 0; return 0; } + if (psx_mod_host_output_scope()) { + if (charged) *charged = 0; + if (counted_cycles) *counted_cycles = 0; + return 0; + } PsxCycleFreeze save; CallerTiming timing; timing_save(&timing, cpu); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f451293b..74e61ccf 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2906,7 +2906,27 @@ extern "C" int psx_mod_reload_hd_texture_pack(void) { return ok; } +/* Host-output observation reads: side-effect-free peeks of RAM, scratchpad + * and BIOS ROM (psx_peek_word); MMIO and anything else reads 0, because a bus + * read of a device register can change the device (SIO data pops the RX FIFO, + * SIO_STAT ages the ACK). */ +extern "C" int psx_mod_host_output_scope(void); +static uint32_t ho_peek_word(uint32_t address) { + uint32_t v = 0; + return psx_peek_word(address & ~3u, &v) ? v : 0u; +} +static uint8_t ho_peek_byte(uint32_t address) { + return (uint8_t)(ho_peek_word(address) >> ((address & 3u) * 8u)); +} +static uint16_t ho_peek_half(uint32_t address) { + return (uint16_t)(ho_peek_word(address) >> ((address & 2u) * 8u)); +} +static void ho_drop_word(uint32_t, uint32_t) {} +static void ho_drop_half(uint32_t, uint16_t) {} +static void ho_drop_byte(uint32_t, uint8_t) {} + extern "C" uint8_t psx_mod_read_byte(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_byte(address); return psx_read_byte(address); } @@ -2938,6 +2958,7 @@ extern "C" void psx_mod_write_byte(uint32_t address, uint8_t value) { } extern "C" uint16_t psx_mod_read_half(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_half(address); return psx_read_half(address); } @@ -2948,6 +2969,7 @@ extern "C" void psx_mod_write_half(uint32_t address, uint16_t value) { } extern "C" uint32_t psx_mod_read_word(uint32_t address) { + if (psx_mod_host_output_scope()) return ho_peek_word(address); return psx_read_word(address); } @@ -3150,7 +3172,9 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address); /* Netplay host-output plugins ([[plugin]] netplay = "host_output") run their * entry hooks in the shared simulation while the rest of the own-view plan is * parked. They observe only: each callback gets a private copy of the CPU - * state (register changes to it are discarded), a completion request is + * state (register changes to it are discarded; its bus pointers peek RAM / + * ROM and drop writes), psx_mod_read_* peek too (no MMIO side effects), a + * completion request is * ignored, and every guest-mutating mod service refuses while one runs * (psx_mod_host_output_scope), so the shared simulation is bit-identical * with or without them. */ @@ -3172,7 +3196,10 @@ static void run_host_output_hooks(CPUState* cpu, uint32_t address) { for (; it != table.end() && it->key == key; ++it) { if (!it->plugin || !it->plugin->netplay_host_output || !it->callback) continue; - CPUState view = *cpu; /* observation copy */ + CPUState view = *cpu; /* observation copy: peek reads, dropped writes */ + view.read_word = ho_peek_word; view.write_word = ho_drop_word; + view.read_half = ho_peek_half; view.write_half = ho_drop_half; + view.read_byte = ho_peek_byte; view.write_byte = ho_drop_byte; PluginCallbackScope scope(s, it->plugin, &view); ++s_host_output_depth; it->callback(&view, address); diff --git a/runtime/tests/test_mod_resident.cpp b/runtime/tests/test_mod_resident.cpp index de3d90d9..f0706ba7 100644 --- a/runtime/tests/test_mod_resident.cpp +++ b/runtime/tests/test_mod_resident.cpp @@ -33,6 +33,8 @@ extern "C" uint16_t psx_read_half(uint32_t a) { extern "C" void psx_write_half(uint32_t a, uint16_t v) { psx_write_byte(a, (uint8_t)v); psx_write_byte(a + 1, (uint8_t)(v >> 8)); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t a) { return (uint32_t)psx_read_half(a) | (uint32_t)psx_read_half(a + 2) << 16; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index a0a6ec1c..7b8ae0f1 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -69,6 +69,8 @@ extern "C" void psx_write_half(uint32_t address, uint16_t value) { ram[offset + 1] = (uint8_t)(value >> 8); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t address) { const uint32_t offset = address & 0x1fffffu; return (uint32_t)ram[offset] | diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index 609289f0..f0087dfb 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "sio.h" #include #include "psx_sha256.h" @@ -39,26 +40,71 @@ extern "C" int gpu_hd_textures_configure(const char*, int, int, char*, size_t) { extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { *out = {}; } extern "C" void gpu_hd_textures_set_dump_enabled(int) {} extern "C" int gpu_hd_textures_reload(char*, size_t) { return 0; } +/* sio.c's environment (as in test_sio_jogcon_protocol.c). */ +extern "C" { +uint32_t i_stat = 0, i_mask = 0; +uint32_t g_debug_current_func_addr = 0, g_debug_last_store_pc = 0; +int psx_get_in_exception(void) { return 0; } +uint64_t psx_get_cycle_count(void) { static uint64_t t; return t += 64; } +uint32_t memory_get_sr(void) { return 0; } +void debug_server_poll(void) {} +void debug_server_log_sio_write(uint32_t, uint32_t, uint8_t) {} +void starvation_ring_record(uint8_t, uint8_t, uint8_t, uint16_t, uint16_t, + uint8_t, uint8_t, uint16_t, uint8_t, uint16_t, + uint8_t, uint32_t) {} +void card_read_summary_record(uint8_t, uint8_t, uint16_t, uint8_t, uint8_t, + uint8_t, uint8_t, uint32_t) {} +void card_data_writes_arm(uint8_t, uint16_t, uint8_t, uint8_t) {} +void event_ring_record_aux(uint16_t, uint8_t, uint32_t) {} +void psx_irq_raise(uint32_t bit, uint32_t) { i_stat |= 1u << bit; } +int memcard_is_present(int) { return 0; } +int memcard_read_sector(int, int, uint8_t* buf) { std::memset(buf, 0, 128); return -1; } +int memcard_write_sector(int, int, const uint8_t*) { return -1; } +void memcard_flush(int) {} +} + +/* The guest bus: main RAM, plus the real SIO (sio.c) at 0x1F801040..5F, so + * a bus read of SIO_DATA / SIO_STAT has its hardware side effects here too. + * Every MMIO bus access is counted. */ +static int mmio_bus_accesses; +static bool is_sio(uint32_t a) { + a &= 0x1fffffffu; + return a >= 0x1F801040u && a < 0x1F801060u; +} extern "C" uint8_t psx_read_byte(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return (uint8_t)sio_read(address); } return ram[address & 0x1fffffu]; } extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } ram[address & 0x1fffffu] = value; } +/* Side-effect-free peek (memory.c): RAM only in this fixture. */ +extern "C" int psx_peek_word(uint32_t addr, uint32_t* out) { + if (is_sio(addr) || (addr & 0x1fffffffu) >= 0x200000u) return 0; + const uint32_t o = addr & 0x1ffffcu; + *out = (uint32_t)ram[o] | ((uint32_t)ram[o + 1] << 8) | + ((uint32_t)ram[o + 2] << 16) | ((uint32_t)ram[o + 3] << 24); + return 1; +} + extern "C" uint16_t psx_read_half(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return (uint16_t)sio_read(address); } const uint32_t offset = address & 0x1fffffu; return (uint16_t)(ram[offset] | ((uint16_t)ram[offset + 1] << 8)); } extern "C" void psx_write_half(uint32_t address, uint16_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } const uint32_t offset = address & 0x1fffffu; ram[offset] = (uint8_t)value; ram[offset + 1] = (uint8_t)(value >> 8); } extern "C" uint32_t psx_read_word(uint32_t address) { + if (is_sio(address)) { ++mmio_bus_accesses; return sio_read(address); } const uint32_t offset = address & 0x1fffffu; return (uint32_t)ram[offset] | ((uint32_t)ram[offset + 1] << 8) | @@ -67,6 +113,7 @@ extern "C" uint32_t psx_read_word(uint32_t address) { } extern "C" void psx_write_word(uint32_t address, uint32_t value) { + if (is_sio(address)) { ++mmio_bus_accesses; sio_write(address, value); return; } const uint32_t offset = address & 0x1fffffu; ram[offset] = (uint8_t)value; ram[offset + 1] = (uint8_t)(value >> 8); @@ -141,11 +188,22 @@ static void view_activate(void) { view_activations++; } static void sim_activate(void) { sim_activations++; } static void view_vblank(void) { vblanks++; } static int out_hits, out_activations, out_scope; +static uint32_t out_peeks[5]; extern "C" int psx_mod_host_output_scope(void); static void out_entry(CPUState* cpu, uint32_t) { out_hits++; out_scope = psx_mod_host_output_scope(); cpu->gpr[2] = 0x12345678u; /* review repro: must not stick */ + /* review 2: MMIO reads through the mod API and the CPU's bus pointers, + * and stores through cpu->write_* (RAM and SIO_CTRL). */ + out_peeks[0] = psx_mod_read_word(0x1F801040u); /* SIO_DATA: pops RX */ + out_peeks[1] = psx_mod_read_half(0x1F801044u); /* SIO_STAT: ages ACK */ + out_peeks[2] = psx_mod_read_byte(0x80004000u); + if (cpu->read_word) out_peeks[3] = cpu->read_word(0x1F801040u); + if (cpu->read_half) out_peeks[4] = cpu->read_half(0x1F801044u); + if (cpu->write_word) cpu->write_word(0x80004004u, 0xDEADBEEFu); + if (cpu->write_half) cpu->write_half(0x1F80104Au, 0x0040u); /* SIO reset */ + if (cpu->write_byte) cpu->write_byte(0x80004008u, 0x77u); cpu->pc = 0x80001234u; psx_mod_write_byte(0x80004000u, 0x5Au); /* must be refused online */ psx_mod_finish_function(cpu); /* must be ignored online */ @@ -222,6 +280,21 @@ int main() { out_hits = 0; cpu = CPUState{}; cpu.gpr[31] = 0x80005550u; + cpu.read_word = psx_read_word; cpu.write_word = psx_write_word; + cpu.read_half = psx_read_half; cpu.write_half = psx_write_half; + cpu.read_byte = psx_read_byte; cpu.write_byte = psx_write_byte; + /* A live pad exchange: select port 1, send 0x01, so SIO holds RX data + * and a pending ACK that a bus read would consume or age. */ + sio_init(); + sio_set_pad_connected(0, 1); + sio_write(0x1F80104Au, 0x1003u); + sio_write(0x1F801040u, 0x01u); + for (int i = 0; i < 4; ++i) (void)sio_peek_stat(); + const uint16_t stat0 = sio_peek_stat(), ctrl0 = sio_peek_ctrl(); + const uint8_t rx0 = sio_peek_rx_data(); + psx_write_word(0x80004004u, 0x11111111u); + psx_write_byte(0x80004008u, 0x22u); + mmio_bus_accesses = 0; const CPUState before = cpu; check(psx_mod_function_entry(&cpu, 0x80003200u) == 0 && out_hits == 1, "a host-output hook runs in the shared simulation, cannot finish it"); @@ -229,6 +302,19 @@ int main() { "host-output scope is set only while its hook runs"); check(cpu.gpr[2] == 0 && std::memcmp(&cpu, &before, sizeof cpu) == 0, "a host-output hook's CPU register changes do not reach the shared CPU"); + check(mmio_bus_accesses == 0, + "a host-output hook makes no MMIO bus access (reads or cpu->write_*)"); + check(sio_peek_stat() == stat0 && sio_peek_ctrl() == ctrl0 && + sio_peek_rx_data() == rx0, + "SIO stat (RX_RDY, ACK), ctrl and RX data unchanged by the hook"); + check(psx_read_word(0x80004004u) == 0x11111111u && + psx_read_byte(0x80004008u) == 0x22u, + "cpu->write_* from a host-output hook do not reach guest RAM"); + check(out_peeks[0] == 0 && out_peeks[1] == 0 && out_peeks[3] == 0 && + out_peeks[4] == 0, + "MMIO observation reads return 0 (no device access)"); + check(out_peeks[2] == psx_read_byte(0x80004000u), + "RAM observation reads still see guest RAM"); check(psx_read_byte(0x80004000u) == 0, "a host-output plugin's guest write is refused online"); psx_mod_function_entry(&cpu, 0x80003000u); diff --git a/runtime/tests/test_pgxp_session.cpp b/runtime/tests/test_pgxp_session.cpp index ad6529f3..6753677f 100644 --- a/runtime/tests/test_pgxp_session.cpp +++ b/runtime/tests/test_pgxp_session.cpp @@ -114,6 +114,8 @@ extern "C" void psx_write_half(uint32_t a, uint16_t v) { ram[o] = (uint8_t)v; ram[o + 1] = (uint8_t)(v >> 8); } +/* Side-effect-free peek (memory.c); host-output observation reads. */ +extern "C" int psx_peek_word(uint32_t, uint32_t* out) { *out = 0; return 0; } extern "C" uint32_t psx_read_word(uint32_t a) { const uint32_t o = a & 0x1fffffu; return (uint32_t)ram[o] | ((uint32_t)ram[o + 1] << 8) | From 5cb8f00e6451773522e3cac96f79ab8b601b1369 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:44:54 -0700 Subject: [PATCH 09/17] host_output test: live SIO RX byte and ACK counter, asserted directly The fixture now completes a pad exchange (sio_tick) so SIO holds a pending RX byte with RX_RDY set and the ACK visible-read counter > 0, and asserts both fields directly after the host-output callback (sio_peek_ack_visible_reads, side-effect free, added for this). Without the fix the callback's reads clear RX_RDY and age the ACK counter, and the test fails on those checks. Co-Authored-By: Claude Opus 5.5 --- runtime/include/sio.h | 2 ++ runtime/src/sio.c | 1 + runtime/tests/test_netplay_view_mods.cpp | 15 +++++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/runtime/include/sio.h b/runtime/include/sio.h index dab3ee58..9b396b74 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -215,6 +215,8 @@ uint16_t sio_get_pad_buttons_slot(int slot); uint16_t sio_peek_stat(void); uint16_t sio_peek_ctrl(void); uint8_t sio_peek_rx_data(void); +/* SIO_STAT reads left before a visible ACK clears (side-effect free). */ +int sio_peek_ack_visible_reads(void); /* Debug accessors: is a logical pad connected, and is it in analog mode. */ int sio_get_pad_connected(int slot); diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 67f6e3b9..e8ead460 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -2172,6 +2172,7 @@ uint32_t sio_read(uint32_t addr) { uint16_t sio_peek_stat(void) { return sio_stat; } uint16_t sio_peek_ctrl(void) { return sio_ctrl; } uint8_t sio_peek_rx_data(void) { return sio_rx_data; } +int sio_peek_ack_visible_reads(void) { return (int)sio_ack_visible_reads; } void sio_write(uint32_t addr, uint32_t value) { sio_debug_poll_maybe(); diff --git a/runtime/tests/test_netplay_view_mods.cpp b/runtime/tests/test_netplay_view_mods.cpp index f0087dfb..da38e817 100644 --- a/runtime/tests/test_netplay_view_mods.cpp +++ b/runtime/tests/test_netplay_view_mods.cpp @@ -287,9 +287,12 @@ int main() { * and a pending ACK that a bus read would consume or age. */ sio_init(); sio_set_pad_connected(0, 1); - sio_write(0x1F80104Au, 0x1003u); - sio_write(0x1F801040u, 0x01u); - for (int i = 0; i < 4; ++i) (void)sio_peek_stat(); + sio_write(0x1F80104Au, 0x1003u); /* TX_EN | SELECT | ACK_IRQ_EN, port 1 */ + sio_write(0x1F801040u, 0x01u); /* pad address byte */ + sio_tick(2000); /* exchange done: RX byte + ACK pending */ + check((sio_peek_stat() & 0x0002u) != 0, "fixture: RX_RDY set (pending RX byte)"); + check(sio_peek_ack_visible_reads() > 0, "fixture: ACK counter > 0"); + const int ack0 = sio_peek_ack_visible_reads(); const uint16_t stat0 = sio_peek_stat(), ctrl0 = sio_peek_ctrl(); const uint8_t rx0 = sio_peek_rx_data(); psx_write_word(0x80004004u, 0x11111111u); @@ -304,9 +307,13 @@ int main() { "a host-output hook's CPU register changes do not reach the shared CPU"); check(mmio_bus_accesses == 0, "a host-output hook makes no MMIO bus access (reads or cpu->write_*)"); + check((sio_peek_stat() & 0x0002u) != 0, + "SIO RX_RDY still set: the hook did not pop the RX byte"); + check(sio_peek_ack_visible_reads() == ack0, + "SIO ACK counter unchanged: the hook's SIO_STAT reads did not age it"); check(sio_peek_stat() == stat0 && sio_peek_ctrl() == ctrl0 && sio_peek_rx_data() == rx0, - "SIO stat (RX_RDY, ACK), ctrl and RX data unchanged by the hook"); + "SIO stat, ctrl and RX data unchanged by the hook"); check(psx_read_word(0x80004004u) == 0x11111111u && psx_read_byte(0x80004008u) == 0x22u, "cpu->write_* from a host-output hook do not reach guest RAM"); From 9720528c1a7dfdd7e1c9dc7b4b0b7934a2ac961e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:26:41 -0700 Subject: [PATCH 10/17] GL fixtures: inert psx_mod_host_output_scope for mod_texture_banks.c The GL fixtures (scale invariance, texture filter / bloom, render thread, frame gen, readback) include mod_texture_banks.c, which now asks psx_mod_host_output_scope(); define it as 0 (no host-output hook runs there). Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_frame_gen.c | 2 ++ runtime/tests/test_gl_readback_region.c | 2 ++ runtime/tests/test_gl_render_thread.c | 2 ++ runtime/tests/test_gl_scale_invariance.c | 2 ++ 4 files changed, 8 insertions(+) diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index 77f37ccb..85c31496 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -53,6 +53,8 @@ void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 86fe92b5..9a5e8350 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -14,6 +14,8 @@ uint64_t psx_cycle_count=0; static int test_full_composite; int gpu_ws_background_requires_full_composite(void){return test_full_composite;} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} static uint16_t image[1024*512], oracle[1024*512]; int g_psx_vram_dirty_tracking=0; diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index e979f8a0..32d276e7 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -49,6 +49,8 @@ void gpu_hd_textures_begin_copy(int sx,int sy,int dx,int dy,int w,int h){ (void)sx;(void)sy;(void)dx;(void)dy;(void)w;(void)h;} void gpu_hd_textures_end_copy(void){} uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 2a40df08..8db9763b 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -84,6 +84,8 @@ #include "gl_batch_policy_hle.c" #include "gl_batch_policy_select.c" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +/* mod_texture_banks.c refuses inside a netplay host-output hook (mod_runtime.cpp); none here. */ +int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; uint64_t s_frame_count=0; From dbe4be60d35756a41b7c20069b485df520a9cd43 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:29:49 -0700 Subject: [PATCH 11/17] GL scale fixture: drop duplicate stubs that slipped into 9720528c Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gl_scale_invariance.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 8db9763b..11f81fe2 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -111,8 +111,6 @@ void psx_ws_dbg_gate_frame_snapshot(void){} void gpu_depth24_upload_span_reset(void){} /* Netplay unsplit view and forward-pass opt-in: off in these fixtures. */ int gpu_ws_netplay_local_viewport_width(void){return 0;} -int gpu_ws_background_requires_full_composite(void){return 0;} -void gpu_timeline_note(uint8_t kind,uint32_t a,uint32_t b){(void)kind;(void)a;(void)b;} int render_pass_netplay_enabled(void){return 0;} /* Guest clock the renderer's stereo-pair freshness reads; test stand-ins, as * in test_gl_readback_region.c. */ From 023af6d9d469eef82094a839ab24c9359d153256 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:58:37 -0700 Subject: [PATCH 12/17] GL: opt-in PS1 dithering ([video] dithering = off|on|scaled) The OpenGL renderer has always drawn shaded and texture-modulated pixels at 8 bits per channel with no dither pattern (true colour). That stays the default. 'on' applies the PS1 4x4 offset matrix and 5-bit truncation per internal-resolution pixel; 'scaled' puts the pattern on the native grid, so it reads like the console at any scale and the native VRAM is the same at every internal resolution. GP0(E1h) bit 9 reaches the backend through a new optional facade hook (gr_set_dither) recorded for the render thread and Smooth motion redraws. With the mode off nothing flushes and no uniform changes. Flat, raw and other 5-bit-exact pixels are never touched. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 7 +- docs/config_schema.md | 25 +++++ recompiler/src/config_loader.cpp | 8 ++ recompiler/src/config_loader.h | 5 + runtime/CMakeLists.txt | 13 +++ runtime/include/gpu_gl_renderer.h | 5 + runtime/include/gpu_render.h | 4 + runtime/src/debug_server.c | 10 ++ runtime/src/gpu.c | 7 ++ runtime/src/gpu_gl_renderer.c | 92 +++++++++++++-- runtime/src/gpu_render.c | 3 + runtime/src/main.cpp | 4 + runtime/tests/gpu_exec_stubs.inc | 1 + runtime/tests/test_gl_dithering.c | 181 ++++++++++++++++++++++++++++++ 14 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_gl_dithering.c diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 05c20cb9..1ba42f6d 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -62,6 +62,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `fmv_chroma` | ✓ | | `on` (optional: 0/1) | Read or set `[video] fmv_chroma_smoothing` live. Replies `on` and `frames` (24-bit presents smoothed so far) | | `texture_lod` | ✓ | | `mode` (optional: 0 off, 1 mipmap), `aniso` (optional: 1..16) | Read or set `[video] texture_lod` / `anisotropic_filtering` live. Replies `mode`, `aniso` | +| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on, 2 scaled) | Read or set `[video] dithering` live. Replies `mode` | | `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 | | `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, queued/pending dumps, composition hits/builds/pixels, GL uploads/evictions, decoded images/evictions/bytes. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data | | `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | @@ -491,9 +492,9 @@ between Play and TCP availability. ## Complete command index (generated) -**369 commands registered** — 356 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**371 commands registered** — 358 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -82 of 369 have prose above; **287 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +83 of 371 have prose above; **288 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -579,6 +580,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `display_ring_aux` | ✓ | | | | `display_ring_get` | ✓ | | | | `display_ring_stats` | ✓ | | | +| `dithering` | ✓ | | ✓ | | `dma_cdrom_history` | ✓ | | | | `dma_hold_ring` | ✓ | | ✓ | | `dma_hold_stats` | ✓ | | ✓ | @@ -654,6 +656,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `history` | ✓ | ✓ | ✓ | | `hle_dump` | ✓ | | ✓ | | `host_launch_timings` | ✓ | | ✓ | +| `host_osd_shot` | ✓ | | | | `host_profile` | ✓ | | | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index 6950f926..5c8a04d1 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -725,6 +725,31 @@ first layer's full 8-bit result where the console would have truncated it to 5 bits first (at most one 5-bit step). OpenGL only. `PSX_ACCURATE_BLENDING=0|1` overrides it for one run and the `accurate_blending` TCP command switches it live. +### Dithering (`dithering`) + +```toml +[video] +dithering = "off" # off (default, true colour) | on | scaled +``` + +The PS1 GPU adds a 4x4 offset pattern to shaded and texture-modulated pixels +before truncating them to 5 bits per channel, when the game sets GP0(E1h) +bit 9. The OpenGL renderer has always drawn those pixels at 8 bits per channel +with no pattern ("true colour"), and `off` keeps exactly that. `on` applies +the PS1 pattern and 5-bit truncation per internal-resolution pixel (a fine +grain that all but vanishes at high scales); `scaled` puts the pattern on the +native pixel grid, so it looks like the console at any scale and the native +VRAM a game reads back is the same at every internal resolution. Flat fills, +flat polygons and raw textures are already 5-bit exact and are never touched. +The pattern is applied to the source colour before semi-transparent blending; +the console dithers the blended result at its final 8-to-5-bit write. Fixed- +function GL blending cannot run a shader after the blend (GL 3.3 has no +framebuffer fetch, and macOS offers none), so a dithered semi-transparent +pixel can differ from the console by one 5-bit step; opaque pixels match. +The dither output follows the surface basis, so it composes with +`accurate_blending`. +OpenGL only. `PSX_DITHERING=0|1|2` overrides it for one run and the +`dithering` TCP command switches it live. ### Texture filtering (`texture_filtering`) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index e874b9a9..b7af12a6 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -840,6 +840,14 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } if (video.contains("accurate_blending")) rt.video_accurate_blending = toml::find(video, "accurate_blending"); + if (video.contains("dithering")) { + const auto mode = toml::find(video, "dithering"); + if (mode == "off") rt.video_dithering = 0; + else if (mode == "on") rt.video_dithering = 1; + else if (mode == "scaled") rt.video_dithering = 2; + else throw std::runtime_error(fmt::format( + "[video] dithering must be \"off\", \"on\" or \"scaled\": {}", mode)); + } if (video.contains("pgxp_depth_buffer")) rt.video_pgxp_depth_buffer = toml::find(video, "pgxp_depth_buffer"); if (video.contains("pgxp_color_correction")) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1cefbd03..6e98a14a 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -580,6 +580,11 @@ struct RuntimeConfig { // semi-transparency truncates exactly like the PS1's 5-bit blend. // Default true: PS1 colour/blend correctness. OpenGL only. bool video_accurate_blending = true; + // dithering: PS1 dither pattern on shaded/modulated draws while the + // guest enables it (GP0(E1h) bit 9). 0 "off" = true colour (default), + // 1 "on" = pattern per internal-resolution pixel, 2 "scaled" = pattern + // on the native pixel grid (the PS1 look at any scale). OpenGL only. + int video_dithering = 0; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ac311475..e39e2cf6 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -364,6 +364,19 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") set_tests_properties(gl_xbr_filter_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # [video] dithering: off ignores the guest's dither bit (byte-identical + # true colour); on/scaled add the PS1 pattern to shaded/modulated draws + # only, through the facade, the render thread, PGXP depth and wide. + add_test(NAME gl_dithering_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_dithering.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-dithering-results") + set_tests_properties(gl_dithering_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index a506078f..e87c7303 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -306,6 +306,11 @@ int gl_renderer_anisotropy(void); * uploads and untextured primitives already are). On by default. */ void gl_renderer_set_accurate_blending(int on); int gl_renderer_accurate_blending(void); +/* [video] dithering: 0 off (true colour, default), 1 PS1 dither pattern at + * internal resolution, 2 pattern scaled to the native pixel grid. Applies to + * shaded/modulated draws while the guest's GP0(E1h) dither bit is set. */ +void gl_renderer_set_dithering(int mode); +int gl_renderer_dithering(void); int gl_renderer_get_pgxp_depth(void); void gl_renderer_set_pgxp_color_perspective(int on); int gl_renderer_get_pgxp_color_perspective(void); diff --git a/runtime/include/gpu_render.h b/runtime/include/gpu_render.h index 7fd5d51a..0b192f17 100644 --- a/runtime/include/gpu_render.h +++ b/runtime/include/gpu_render.h @@ -61,6 +61,9 @@ void gr_set_precise_triangle(int enabled, * perspective_texturing). q[i] is the normalized 1/z homogeneous weight at * vertex i. enabled == 0 restores the PS1's affine UV interpolation. */ void gr_set_perspective_triangle(int enabled, float q0, float q1, float q2); +/* GP0(E1h) bit 9 (dither enable). Only a backend with [video] dithering + * implements it; the default renderers stay true colour. */ +void gr_set_dither(int enabled); /* PGXP depth for the NEXT triangle (docs/ENHANCEMENTS.md G1.14): the GTE SZ * of each vertex (1..65535), taken from validated dataflow shadows. Only a * backend with a PGXP depth buffer / perspective-correct colour uses it; @@ -167,6 +170,7 @@ typedef struct GpuRenderBackend { int32_t x1, int32_t y1, int32_t x2, int32_t y2); void (*set_perspective_triangle)(int enabled, float q0, float q1, float q2); + void (*set_dither)(int enabled); /* optional: GP0(E1h) bit 9 */ void (*fill_rect)(int x, int y, int w, int h, uint16_t color); void (*copy_rect)(int src_x, int src_y, int dst_x, int dst_y, int w, int h); void (*draw_flat_triangle)(int x0, int y0, int x1, int y1, int x2, int y2, diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 6796c5cb..2c20224c 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8649,6 +8649,15 @@ static void handle_texture_filter(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d}", id, gr_texture_filter()); } +/* dithering: {"cmd":"dithering"} reads, {"cmd":"dithering","mode":0|1|2} + * sets [video] dithering live (off, on, scaled). */ +static void handle_dithering(int id, const char *json) +{ + int mode = json_get_int(json, "mode", -1); + if (mode >= 0) gl_renderer_set_dithering(mode); + send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d}", id, gl_renderer_dithering()); +} + static void handle_frame_perf(int id, const char *json) { (void)json; @@ -15922,6 +15931,7 @@ static const CmdEntry s_commands[] = { { "accurate_blending", handle_accurate_blending }, { "texture_filter", handle_texture_filter }, + { "dithering", handle_dithering }, { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8101a9e8..cd7401dc 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -3831,6 +3831,7 @@ static void gpu_reset_state(int clear_vram) { semi_transparency = 0; texpage_colors = 0; dither_enabled = 0; + gr_set_dither(0); draw_to_display = 0; set_mask_bit = 0; check_mask_bit = 0; @@ -5941,6 +5942,7 @@ static void gp0_exec_draw_mode(void) { semi_transparency = (param >> 5) & 3; texpage_colors = (param >> 7) & 3; dither_enabled = (param >> 9) & 1; + gr_set_dither((int)dither_enabled); draw_to_display = (param >> 10) & 1; texture_disable = (param >> 11) & 1; /* Sync semi-transparency to SW renderer — actual blending is per-primitive */ @@ -7915,6 +7917,10 @@ int gpu_snapshot_read(const uint8_t *p, uint32_t len) { * on a stale draw area after savestate load. */ gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); + /* GP0(E1h) bit 9 is restored with the registers; the renderer only + * learns it from E1, so push it now (else dithering keeps the pre-load + * setting until the game's next E1). */ + gr_set_dither((int)dither_enabled); /* A state saved mid-A0 resumes streaming its payload into the array. */ gr_vram_upload_set_open(gp0_state == GP0_VRAM_WRITE); ws_nw_sync_target(); @@ -8069,6 +8075,7 @@ void gpu_pass_checkpoint_restore(void) { (int)draw_area_right, (int)draw_area_bottom); gr_set_draw_offset(draw_offset_x, draw_offset_y); gr_set_mask_bits((int)set_mask_bit, (int)check_mask_bit); + gr_set_dither((int)dither_enabled); ws_nw_sync_target(); /* Target sync may classify the scene before render_pass.c restores RAM. * Do not retain history learned from the synthetic draw or that query. */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index c375e9ec..9be6c379 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -462,7 +462,7 @@ enum { RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE, RTH_DYN_STEP, - RTH_FRAME, RTH_FG_SRC, RTH_DEPTH, RTH_HD_NOTE + RTH_FRAME, RTH_FG_SRC, RTH_DEPTH, RTH_HD_NOTE, RTH_DITHER }; /* GP0(A0) whose payload gpu.c is still streaming into its array (the * facade's vram_upload_open, emulation thread), tracked whatever the HD @@ -920,6 +920,11 @@ static void c5_to_float(uint16_t c, float *r, float *g, float *b) { *r = (c & 0x1F) / 31.0f; *g = ((c >> 5) & 0x1F) / 31.0f; *b = ((c >> 10) & 0x1F) / 31.0f; } } +/* [video] dithering (see psx_dither in the shaders): 0 off = true colour (the + * historical path), 1 the PS1 4x4 pattern at internal resolution, 2 scaled to + * the native pixel grid. s_dither_bit is the last GP0(E1h) bit 9; the shaders + * only ever see s_dither_mode while the guest has dithering enabled. */ +static int s_dither_mode = 0, s_dither_bit = 0, s_dither_live = 0; /* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the * duplicate stencil copy current is deferred until mask checking is requested. */ static int s_stencil_valid = 1; @@ -1813,6 +1818,27 @@ static const char *INTERP_FS = * rect coverage exactly [x*S, (x+w)*S) at every scale AND makes the * top-left subpixel of each S*S block sample the exact PS1 value (which is * what the PACK pass reads back). */ +/* PS1 dithering ([video] dithering). The GPU adds the 4x4 offset matrix below + * to a shaded or texture-modulated pixel before truncating it to 5 bits, when + * GP0(E1h) bit 9 is set. Flat and raw-texture pixels are already 5-bit exact + * and are left alone, which is what the hardware's shading/modulation rule + * amounts to. u_dither 0 = off (true colour, the default and the historical + * renderer), 1 = pattern per internal-resolution pixel, 2 = pattern scaled + * to the native grid (v_npos is the fragment's native VRAM position). */ +#define PSX_DITHER_GLSL \ + "uniform int u_dither;\n" \ + "uniform int u_accurate; /* [video] accurate_blending: VRAM in the k*8/255 basis */\n" \ + "vec3 psx_dither(vec3 c, vec2 npos){\n" \ + " if (u_dither == 0) return c;\n" \ + " vec3 q8 = c * (255.0 / 8.0), q31 = c * 31.0;\n" \ + " if (all(lessThan(abs(q8 - floor(q8 + 0.5)), vec3(0.02))) ||\n" \ + " (u_accurate == 0 && all(lessThan(abs(q31 - floor(q31 + 0.5)), vec3(0.02))))) return c;\n" \ + " ivec2 p = (u_dither == 2 ? ivec2(floor(npos)) : ivec2(gl_FragCoord.xy)) & 3;\n" \ + " const float m[16] = float[16](-4.0,0.0,-3.0,1.0, 2.0,-2.0,3.0,-1.0,\n" \ + " -3.0,1.0,-4.0,0.0, 3.0,-1.0,2.0,-2.0);\n" \ + " vec3 k = clamp(floor((c * 255.0 + m[p.y * 4 + p.x]) / 8.0), 0.0, 31.0);\n" \ + " return u_accurate != 0 ? k * (8.0 / 255.0) : k / 31.0; /* the surface's basis */\n" \ + "}\n" static const char *GEO_VS = "#version 330\n" "layout(location=0) in vec2 a_pos;\n" @@ -1826,7 +1852,9 @@ static const char *GEO_VS = "noperspective out vec4 v_col;\n" "smooth out vec3 v_col_p; /* perspective-correct colour (PGXP, G1.14) */\n" "flat out int v_cp;\n" + "noperspective out vec2 v_npos; /* native VRAM position (dithering) */\n" "void main(){\n" + " v_npos = a_pos;\n" " /* a_col.a carries the mask bit (0/1) and, for a PGXP 3D vertex, its\n" " * GTE SZ: a = mask + 2*(sz + 65536*cp), cp = perspective colour. A\n" " * negative a is a depth-clear vertex (beyond every SZ, inside the far plane). */\n" @@ -1846,7 +1874,10 @@ static const char *GEO_VS = static const char *GEO_FS = "#version 330\n" "noperspective in vec4 v_col; smooth in vec3 v_col_p; flat in int v_cp; out vec4 frag;\n" - "void main(){ frag = v_cp != 0 ? vec4(v_col_p, v_col.a) : v_col; }\n"; + "noperspective in vec2 v_npos;\n" + PSX_DITHER_GLSL + "void main(){ frag = v_cp != 0 ? vec4(v_col_p, v_col.a) : v_col;\n" + " frag.rgb = psx_dither(frag.rgb, v_npos); }\n"; /* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, * texture window, optional bilinear, texel-0 discard, STP-split discard, @@ -1892,7 +1923,9 @@ static const char *TEX_VS = "flat out vec4 v_hd_source; flat out int v_hd_mode;\n" "flat out int v_filter;\n" "smooth out vec3 v_col_p; flat out int v_cp;\n" + "noperspective out vec2 v_npos; /* native VRAM position (dithering) */\n" "void main(){ v_uv = a_uv; v_uv_p = a_uv; v_col = a_col; v_col_p = a_col.rgb;\n" + " v_npos = a_pos;\n" " v_persp = (a_q > 0.0) ? 1 : 0;\n" " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" @@ -1928,6 +1961,8 @@ static const char *TEX_FS = "noperspective in vec2 v_uv; noperspective in vec4 v_col;\n" "smooth in vec2 v_uv_p; flat in int v_persp;\n" "smooth in vec3 v_col_p; flat in int v_cp;\n" + "noperspective in vec2 v_npos;\n" + PSX_DITHER_GLSL "out vec4 frag; out vec4 blend_factor;\n" "flat in ivec2 v_tpage; /* texture page base, VRAM px */\n" "flat in ivec2 v_clut; /* CLUT base, VRAM px */\n" @@ -1977,7 +2012,6 @@ static const char *TEX_FS = " }\n" " return vram_at(v_tpage.x + u, v_tpage.y + v);\n" "}\n" - "uniform int u_accurate; /* [video] accurate_blending: VRAM in the k*8/255 basis */\n" "vec3 col5(int raw){\n" " vec3 k = vec3(float(raw & 31), float((raw >> 5) & 31), float((raw >> 10) & 31));\n" " return u_accurate != 0 ? k * (8.0 / 255.0) : k / 31.0;\n" @@ -2159,7 +2193,7 @@ static const char *TEX_FS = " if ((v_filter & 16) != 0 && v_hd_mode == 0) rgb = lod_sample(uv0, dx0, dy0, stp, rgb);\n" " if (u_semipass == 1 && stp == 1) discard;\n" " if (u_semipass == 2 && stp == 0) discard;\n" - " if (v_raw == 0) rgb = clamp(rgb * (v_cp != 0 ? v_col_p : v_col.rgb) * 2.0, 0.0, 1.0);\n" + " if (v_raw == 0) rgb = psx_dither(clamp(rgb * (v_cp != 0 ? v_col_p : v_col.rgb) * 2.0, 0.0, 1.0), v_npos);\n" " float dst_factor = 0.0;\n" " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" @@ -5037,12 +5071,15 @@ void gl_renderer_set_texture_lod(int mode, int aniso) { } int gl_renderer_texture_lod(void) { return s_tex_lod; } int gl_renderer_anisotropy(void) { return s_tex_aniso; } -static GLint s_uAccurate = -1; +static GLint s_uAccurate = -1, s_geo_uAccurate = -1; static void accurate_apply_uniforms(void) { if (!s_tex_prog) return; GLint prev = 0; glGetIntegerv(GL_CURRENT_PROGRAM, &prev); if (s_uAccurate < 0) s_uAccurate = p_glGetUniformLocation(s_tex_prog, "u_accurate"); + if (s_geo_uAccurate < 0 && s_geo_prog) s_geo_uAccurate = p_glGetUniformLocation(s_geo_prog, "u_accurate"); p_glUseProgram(s_tex_prog); p_glUniform1i(s_uAccurate, s_accurate_blend); + /* The dither snippet in the geometry program follows the same basis. */ + if (s_geo_prog) { p_glUseProgram(s_geo_prog); p_glUniform1i(s_geo_uAccurate, s_accurate_blend); } p_glUseProgram((GLuint)prev); } void gl_renderer_set_accurate_blending(int on) { @@ -5052,6 +5089,38 @@ void gl_renderer_set_accurate_blending(int on) { if (s_ctx) accurate_apply_uniforms(); } int gl_renderer_accurate_blending(void) { return s_accurate_blend; } +/* Dither uniforms live in the geometry and textured programs. The effective + * value only changes between batches (glb_set_dither drains them first). */ +static GLint s_geo_uDither = -1, s_tex_uDither = -1; +static void dither_apply_uniforms(void) { + if (!s_geo_prog || !s_tex_prog) return; + GLint prev = 0; glGetIntegerv(GL_CURRENT_PROGRAM, &prev); + if (s_geo_uDither < 0) s_geo_uDither = p_glGetUniformLocation(s_geo_prog, "u_dither"); + if (s_tex_uDither < 0) s_tex_uDither = p_glGetUniformLocation(s_tex_prog, "u_dither"); + p_glUseProgram(s_geo_prog); p_glUniform1i(s_geo_uDither, s_dither_live); + p_glUseProgram(s_tex_prog); p_glUniform1i(s_tex_uDither, s_dither_live); + p_glUseProgram((GLuint)prev); +} +static void dither_update(void) { + const int live = s_dither_bit ? s_dither_mode : 0; + if (live == s_dither_live) return; + /* Queued draws keep the state they were submitted under. */ + flush_flat_batch(); flush_tex_batch(); hiw_flush_queue(); + s_dither_live = live; + dither_apply_uniforms(); +} +/* GP0(E1h) bit 9. With [video] dithering off (the default) this only records + * the bit; nothing is flushed and no uniform changes. */ +static void glb_set_dither(int on) { + s_dither_bit = on ? 1 : 0; + if (s_dither_mode || s_dither_live) dither_update(); +} +void gl_renderer_set_dithering(int mode) { + GL_RT_SYNC("dithering"); + s_dither_mode = (mode == 1 || mode == 2) ? mode : 0; + if (s_ctx) dither_update(); +} +int gl_renderer_dithering(void) { return s_dither_mode; } static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 3 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } @@ -5916,7 +5985,7 @@ static int init_gpu_raster(void) { s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); s_uAniso = -1; lod_apply_uniforms(); - s_uAccurate = -1; + s_uAccurate = s_geo_uAccurate = -1; accurate_apply_uniforms(); s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); s_uHdTexture = p_glGetUniformLocation(s_tex_prog, "u_hd_texture"); @@ -5966,6 +6035,9 @@ static int init_gpu_raster(void) { s_shift_hr = shift; s_shift_hi = 0.5f / (float)s_out_scale - 1.0f / 64.0f; s_geo_uShift = p_glGetUniformLocation(s_geo_prog, "u_shift"); + s_geo_uDither = s_tex_uDither = -1; + s_dither_live = s_dither_bit ? s_dither_mode : 0; + dither_apply_uniforms(); s_tex_uShift = p_glGetUniformLocation(s_tex_prog, "u_shift"); s_geo_uZbias = p_glGetUniformLocation(s_geo_prog, "u_zbias"); s_tex_uZbias = p_glGetUniformLocation(s_tex_prog, "u_zbias"); @@ -10628,6 +10700,7 @@ static const GpuRenderBackend GL_BACKEND = { .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, .set_precise_triangle = glb_set_precise_triangle, .set_perspective_triangle = glb_set_perspective_triangle, + .set_dither = glb_set_dither, .fill_rect = glb_fill_rect, .copy_rect = glb_copy_rect, .draw_flat_triangle = glb_draw_flat_triangle, .draw_gouraud_triangle = glb_draw_gouraud_triangle, .draw_textured_triangle = glb_draw_textured_triangle, @@ -11285,7 +11358,7 @@ static int fg_raw_add(const FgRaw *r) { static int fg_op_kept(uint16_t op) { switch (op) { case RTH_SEMI: case RTH_MASK: case RTH_TWIN: case RTH_MOD: case RTH_PRECISE: - case RTH_PERSP: case RTH_DEPTH: case RTH_FILL: case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: + case RTH_PERSP: case RTH_DEPTH: case RTH_DITHER: case RTH_FILL: case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: case RTH_FLAT_RECT: case RTH_TEX_RECT: case RTH_TEX_RECT_SCALED: case RTH_LINE: case RTH_SHADED_LINE: case RTH_AREA: case RTH_OFFSET: case RTH_WIDE_VIEW: case RTH_WIDE_TARGET: case RTH_WIDE_DISABLE: @@ -11756,6 +11829,7 @@ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wid const int in = !s_fg_replay_objects_only && fg_area_meets(area, disp); switch (h.op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; + case RTH_DITHER: glb_set_dither(v[0]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; case RTH_TWIN: glb_set_texture_window((uint32_t)v[0]); break; case RTH_MOD: glb_set_color_modulation(v[0], v[1], v[2], v[3]); break; @@ -13236,6 +13310,8 @@ static int rtb_scale(void) { } static void rtb_set_texture_filter(int b) { GL_RT_SYNC("texture_filter"); glb_set_texture_filter(b); } static int rtb_texture_filter(void) { return s_tex_filter; } +static void rtb_set_dither(int on) { + RTH_DIRECT_OR(RTH_REC(RTH_DITHER, 0, on ? 1 : 0)); glb_set_dither(on); } static void rtb_set_semi_transparency(int e, int m) { RTH_DIRECT_OR(RTH_REC(RTH_SEMI, 0, e, m)); glb_set_semi_transparency(e, m); } static void rtb_set_mask_bits(int sb, int cb) { @@ -13444,6 +13520,7 @@ static const GpuRenderBackend GL_RT_BACKEND = { .set_texture_window = rtb_set_texture_window, .set_color_modulation = rtb_set_color_modulation, .set_precise_triangle = rtb_set_precise_triangle, .set_perspective_triangle = rtb_set_perspective_triangle, + .set_dither = rtb_set_dither, .fill_rect = rtb_fill_rect, .copy_rect = rtb_copy_rect, .draw_flat_triangle = rtb_draw_flat_triangle, .draw_gouraud_triangle = rtb_draw_gouraud_triangle, .draw_textured_triangle = rtb_draw_textured_triangle, @@ -13482,6 +13559,7 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { if (s_fg_on) fg_capture(c, payload); switch (c->op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; + case RTH_DITHER: glb_set_dither(v[0]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; case RTH_TWIN: glb_set_texture_window((uint32_t)v[0]); break; case RTH_MOD: glb_set_color_modulation(v[0], v[1], v[2], v[3]); break; diff --git a/runtime/src/gpu_render.c b/runtime/src/gpu_render.c index 812de659..c73d7c04 100644 --- a/runtime/src/gpu_render.c +++ b/runtime/src/gpu_render.c @@ -177,6 +177,9 @@ void gr_set_perspective_triangle(int enabled, float q0, float q1, float q2) { if (g_b->set_perspective_triangle) g_b->set_perspective_triangle(enabled, q0, q1, q2); } +void gr_set_dither(int enabled) { + if (g_b->set_dither) g_b->set_dither(enabled); +} void gr_set_depth_triangle(int enabled, float z0, float z1, float z2) { if (g_b->set_depth_triangle) g_b->set_depth_triangle(enabled, z0, z1, z2); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index de26182c..9ec8d55b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1294,6 +1294,7 @@ static int g_video_fmv_chroma = 0; /* [video] fmv_chroma_smoothing / /* [video] texture_lod / anisotropic_filtering; PSX_TEXTURE_LOD / PSX_ANISO. */ static int g_video_texture_lod = 0, g_video_aniso = 1; static int g_video_accurate_blending = 1; /* [video] accurate_blending / PSX_ACCURATE_BLENDING */ +static int g_video_dithering = 0; /* [video] dithering / PSX_DITHERING */ static int g_video_pgxp_color_correction = 0; static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; @@ -16354,6 +16355,7 @@ int main(int argc, char** argv) { g_video_texture_lod = gc.runtime.video_texture_lod; g_video_aniso = gc.runtime.video_anisotropic_filtering; g_video_accurate_blending = gc.runtime.video_accurate_blending ? 1 : 0; + g_video_dithering = gc.runtime.video_dithering; g_video_pgxp_color_correction = gc.runtime.video_pgxp_color_correction ? 1 : 0; g_video_pgxp_seam = gc.runtime.video_pgxp_seam; g_video_pgxp_depth_threshold = (float)gc.runtime.video_pgxp_depth_threshold; @@ -18786,6 +18788,8 @@ session_reboot: gl_renderer_set_texture_lod(g_video_texture_lod, g_video_aniso); if (const char* e = std::getenv("PSX_ACCURATE_BLENDING")) g_video_accurate_blending = (*e && *e != '0'); gl_renderer_set_accurate_blending(g_video_accurate_blending); + if (const char* e = std::getenv("PSX_DITHERING")) g_video_dithering = std::atoi(e); + gl_renderer_set_dithering(g_video_dithering); gl_renderer_set_pgxp_color_perspective(g_video_pgxp_color_correction); gl_renderer_set_pgxp_seam(g_video_pgxp_seam); if (const char* e = std::getenv("PSX_PGXP_DEPTH_THRESHOLD")) g_video_pgxp_depth_threshold = (float)std::atof(e); diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index b26c1bcf..4b1e98d7 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -272,6 +272,7 @@ int gr_scale(void) { return 1; } void gr_set_texture_filter(int bilinear) { (void)bilinear; } int gr_texture_filter(void) { return 0; } void gr_set_semi_transparency(int enabled, int mode) { (void)enabled; (void)mode; } +void gr_set_dither(int enabled) { (void)enabled; } void gr_set_mask_bits(int set_bit, int check_bit) { (void)set_bit; (void)check_bit; } void gr_set_texture_window(uint32_t raw) { (void)raw; } void gr_set_color_modulation(int r, int g, int b, int raw_texture) { diff --git a/runtime/tests/test_gl_dithering.c b/runtime/tests/test_gl_dithering.c new file mode 100644 index 00000000..92245675 --- /dev/null +++ b/runtime/tests/test_gl_dithering.c @@ -0,0 +1,181 @@ +/* [video] dithering on a real, hidden OpenGL context, driven through the + * renderer facade (gr_*) the way gpu.c drives it. + * + * - off (the default): the GP0(E1h) dither bit changes nothing, so the + * native VRAM and the frame at internal resolution are byte-identical + * with the bit set or clear; + * - on / scaled with the bit clear: identical to off; + * - on / scaled with the bit set: a gouraud gradient and a modulated + * texture gain the PS1 4x4 pattern (more 5-bit steps along a row, same + * mean brightness), while flat fills, flat polygons and raw textures stay + * exactly as they were; + * - "scaled" puts the pattern on the native grid: the native VRAM at 4x + * (stepped by dynamic resolution) equals the native VRAM at 1x; + * - the render thread (records replayed later), PGXP depth and native-wide + * margins give the same pixels as the direct path. + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +#define DW 320 +#define DH 240 +static uint16_t nat[1024 * 512]; +static uint32_t hi[DW * 4 * DH * 4]; +static int k_test_scale = 4; + +static void scene(int bit, int depth) { + gr_set_draw_area(0, 0, DW - 1, DH - 1); + gr_set_draw_offset(0, 0); + gr_wide_set_target(0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gr_set_dither(0); + gr_fill_rect(0, 0, DW, DH, 0x0000); + gr_wide_clear(0, 0, DH, 0x0000); + gr_set_dither(bit); + /* A horizontal gouraud gradient, black to white (rows 0..63), reaching + * into the left native-wide margin. */ + if (depth) gr_set_depth_triangle(1, 900.0f, 900.0f, 900.0f); + gr_draw_gouraud_triangle(-40, 0, 0x0000, 256, 0, 0x7fff, -40, 64, 0x0000); + if (depth) gr_set_depth_triangle(1, 900.0f, 900.0f, 900.0f); + gr_draw_gouraud_triangle(256, 0, 0x7fff, 256, 64, 0x7fff, -40, 64, 0x0000); + /* Flat polygon and flat rect (never dithered). */ + gr_draw_flat_rect(0, 80, 64, 32, 0x4210); + gr_draw_flat_triangle(80, 80, 140, 80, 80, 112, 0x2d6b); + /* Modulated and raw textured draws. */ + gr_set_color_modulation(128, 128, 128, 0); + gr_draw_shaded_textured_triangle(160, 80, 0, 0, 0x404040, 300, 80, 63, 0, 0xa0a0a0, + 160, 200, 0, 63, 0x606060, 512, 256, 0x0008, 0); + gr_set_color_modulation(128, 128, 128, 1); + gr_draw_textured_rect(0, 130, 64, 64, 0, 0, 512, 256, 0x0008); + gr_set_color_modulation(128, 128, 128, 0); + gr_set_dither(0); + gl_renderer_render_thread_frame_boundary(); +} + +typedef struct { uint64_t native, hires, wide; } Shot; +static Shot shot(void) { + Shot s; + gr_vram_transfer_out(0, 0, 1024, 512, nat); + s.native = fnv(nat, sizeof nat, 0xcbf29ce484222325ull); + int ow = 0, oh = 0, sc = gr_scale(); + int n = gl_renderer_read_display_hires(0, 0, DW, DH, hi, DW * sc * DH * sc, &ow, &oh); + check(n == DW * sc * DH * sc, "hires readback"); + s.hires = fnv(hi, (size_t)n * 4, 0xcbf29ce484222325ull); + static uint32_t wb[426 * 4 * 512 * 4]; + int gw = 0, gh = 0, got = gr_wide_dump_full(wb, 426 * sc * 512 * sc, &gw, &gh, 0); + s.wide = got > 0 ? fnv(wb, (size_t)got * 4, 0xcbf29ce484222325ull) : 0; + return s; +} +static int same(Shot a, Shot b) { return a.native == b.native && a.hires == b.hires && a.wide == b.wide; } +static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { + for (int r = y; r < y + h; r++) + if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; + return 1; +} +/* Along the gradient's row 30: steps between neighbours, and the mean red. */ +static void row_stats(const uint16_t *v, int *steps, double *mean) { + int s = 0; long sum = 0; + for (int x = 0; x < 250; x++) { + int r = v[30 * 1024 + x] & 31, n = v[30 * 1024 + x + 1] & 31; + s += r != n; sum += r; + } + *steps = s; *mean = (double)sum / 250.0; +} + +int main(void) { + if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video\n"); return 77; } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Dithering hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(k_test_scale); + gl_renderer_set_dynamic_resolution(1); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + textures(); + gr_wide_configure(426, 53); + static uint16_t ref[1024 * 512], dith[1024 * 512]; + + /* Off: the bit is ignored. */ + check(gl_renderer_dithering() == 0, "off by default"); + scene(0, 0); Shot off0 = shot(); memcpy(ref, nat, sizeof ref); + scene(1, 0); Shot off1 = shot(); + check(same(off0, off1), "off: dither bit changes nothing"); + for (int mode = 1; mode <= 2; mode++) { + gl_renderer_set_dithering(mode); + scene(0, 0); Shot clear = shot(); + check(same(off0, clear), "on/scaled with the bit clear equals off"); + scene(1, 0); Shot on = shot(); + check(on.native != off0.native && on.hires != off0.hires, "bit set dithers"); + check(on.wide != off0.wide, "native-wide margin dithers too"); + memcpy(dith, nat, sizeof dith); + check(region_equal(ref, dith, 0, 80, 150, 32), "flat polygons untouched"); + check(region_equal(ref, dith, 0, 130, 64, 64), "raw texture untouched"); + check(!region_equal(ref, dith, 160, 80, 140, 120), "modulated texture dithered"); + int s0, s1; double m0, m1; + row_stats(ref, &s0, &m0); row_stats(dith, &s1, &m1); + printf("mode=%d steps true=%d dithered=%d mean true=%.2f dithered=%.2f\n", mode, s0, s1, m0, m1); + /* "on" dithers per internal pixel, so the native pack (each block's + * top-left sample) sees one pattern column; "scaled" all four. */ + check(mode == 2 ? s1 > s0 * 3 : s1 > s0, "dither pattern adds 5-bit steps"); + check(fabs(m1 - m0) < 1.0, "dither keeps the mean brightness"); + for (int x = 0; x < 250; x++) { + uint16_t p = dith[30 * 1024 + x]; + check((p & 31) == ((p >> 5) & 31) && (p & 31) == ((p >> 10) & 31), "grey stays grey"); + } + /* PGXP depth: the same 2D-depth scene, depth-tested. */ + gl_renderer_set_pgxp_depth(1); + scene(1, 1); Shot dep = shot(); + check(dep.native == on.native, "PGXP depth path dithers the same"); + gl_renderer_set_pgxp_depth(0); + /* Render thread: dither bit and mode recorded with the draws. */ + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + scene(1, 0); + gl_renderer_render_thread_sync("dither"); + Shot rt = shot(); + gl_renderer_render_thread_stop(); + check(same(rt, on), "render thread gives the same pixels"); + /* With accurate_blending the dither output uses the k*8 basis, like + * every other write: every gradient channel at internal resolution + * is a multiple of 8 (no k*255/31 values mixed in). */ + gl_renderer_set_accurate_blending(1); + scene(1, 0); Shot acc = shot(); + int off_basis = 0; + for (int y = 0; y < 60 * gr_scale(); y++) + for (int x = 0; x < 250 * gr_scale(); x++) { + uint32_t p = hi[y * DW * gr_scale() + x]; + off_basis += ((p & 0xff) % 8) != 0; + } + int sa; double ma; + row_stats(nat, &sa, &ma); + printf("mode=%d accurate: mean=%.2f channels off the k*8 grid=%d\n", mode, ma, off_basis); + check(fabs(ma - m0) < 1.0, "dither + accurate blending keeps the mean"); + check(off_basis == 0, "dither output follows the accurate (k*8) basis"); + gl_renderer_set_accurate_blending(0); + if (mode == 2) { + /* Scaled: the pattern sits on the native grid at any scale. */ + check(gl_renderer_step_internal_scale_now(1) == 1, "step to 1x"); + scene(1, 0); Shot one = shot(); + check(one.native == on.native, "scaled: native VRAM at 4x equals 1x"); + check(gl_renderer_step_internal_scale_now(k_test_scale) == 1, "back to 4x"); + } + } + gl_renderer_set_dithering(0); + scene(1, 0); Shot back = shot(); + check(same(back, off0), "back off restores the exact true-colour image"); + check(glGetError() == GL_NO_ERROR, "GL errors"); + printf("off native=%016llx hires=%016llx\n", (unsigned long long)off0.native, + (unsigned long long)off0.hires); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return failures ? 1 : 0; +} From b3d24a479613d04a9e3539af57bd5a1c4dbe7e57 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:45:01 -0700 Subject: [PATCH 13/17] gpu_gp0_history_guard_test: stub gr_set_dither Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_gpu_gp0_history_guard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/tests/test_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index be57353d..87b2e24b 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -404,6 +404,7 @@ void gr_set_draw_offset(int x, int y) { (void)x; (void)y; } void gr_set_mask_bits(int set, int check) { (void)set; (void)check; } void gr_set_texture_window(uint32_t value) { (void)value; } void gr_set_semi_transparency(int enabled, int mode) { (void)enabled; (void)mode; } +void gr_set_dither(int enabled) { (void)enabled; } void gr_set_color_modulation(int r, int g, int b, int raw) { (void)r; (void)g; (void)b; (void)raw; } void gr_set_perspective_triangle(int enabled, float a, float b, float c) From abbae4894728cfa1c35c7465ecce104799b86792 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:59:08 -0700 Subject: [PATCH 14/17] Dithering on by default with explicit per-primitive eligibility Maintainer review of #584: PS1 dithering is hardware behaviour, so it is now on by default whenever the guest sets GP0(E1h) bit 9 ([video] dithering = "on"; "scaled" stays opt-in, "off" is the old true-colour output). Eligibility is decided per primitive from its command and carried in the vertex instead of being inferred from the colour: - Gouraud polygons and shaded lines: geometry vertex a_col.a + 2*131072 (decoded in GEO_VS, v_dith); - texture-modulated polygons: a_semi + 8 (v_dith in TEX_VS); - flat polygons/lines, raw-texture polygons and every rectangle: never. This fixes the two reproduced bugs (Gouraud with 5-bit-exact colours was skipped; textured rectangles were dithered). Bit changes no longer flush: eligibility is fixed when the primitive is queued; mode changes still drain. gl_dithering_test covers each case (Gouraud incl. equal colours, shaded line, modulated and neutral-modulated textured polys dithered; flat tri, flat rect, flat line, raw poly, modulated and raw textured rects never; bit clear and off unchanged), plus scaled 4x == 1x, render thread, PGXP depth, native-wide and the accurate-blending basis. OpenBIOS stamp regenerated. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 2 +- docs/config_schema.md | 22 ++-- recompiler/src/config_loader.h | 10 +- runtime/src/gpu_gl_renderer.c | 111 ++++++++++-------- runtime/src/main.cpp | 2 +- runtime/tests/test_gl_dithering.c | 181 +++++++++++++++++------------- 6 files changed, 188 insertions(+), 140 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 1ba42f6d..33d51086 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -62,7 +62,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `fmv_chroma` | ✓ | | `on` (optional: 0/1) | Read or set `[video] fmv_chroma_smoothing` live. Replies `on` and `frames` (24-bit presents smoothed so far) | | `texture_lod` | ✓ | | `mode` (optional: 0 off, 1 mipmap), `aniso` (optional: 1..16) | Read or set `[video] texture_lod` / `anisotropic_filtering` live. Replies `mode`, `aniso` | -| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on, 2 scaled) | Read or set `[video] dithering` live. Replies `mode` | +| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on (default), 2 scaled) | Read or set `[video] dithering` live. Replies `mode` | | `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 | | `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, queued/pending dumps, composition hits/builds/pixels, GL uploads/evictions, decoded images/evictions/bytes. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data | | `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | diff --git a/docs/config_schema.md b/docs/config_schema.md index 5c8a04d1..f47ec3ce 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -729,18 +729,20 @@ first layer's full 8-bit result where the console would have truncated it to ```toml [video] -dithering = "off" # off (default, true colour) | on | scaled +dithering = "on" # on (default) | scaled | off (true colour) ``` -The PS1 GPU adds a 4x4 offset pattern to shaded and texture-modulated pixels -before truncating them to 5 bits per channel, when the game sets GP0(E1h) -bit 9. The OpenGL renderer has always drawn those pixels at 8 bits per channel -with no pattern ("true colour"), and `off` keeps exactly that. `on` applies -the PS1 pattern and 5-bit truncation per internal-resolution pixel (a fine -grain that all but vanishes at high scales); `scaled` puts the pattern on the -native pixel grid, so it looks like the console at any scale and the native -VRAM a game reads back is the same at every internal resolution. Flat fills, -flat polygons and raw textures are already 5-bit exact and are never touched. +The PS1 GPU dithers while the game sets GP0(E1h) bit 9: it adds a 4x4 +offset pattern to every pixel of a Gouraud-shaded polygon or line and of a +texture-modulated polygon, then truncates to 5 bits per channel. Flat +untextured primitives, raw-texture polygons and all rectangles (sprites) are +never dithered. The OpenGL renderer does the same by default (`on`), with the +pattern on internal-resolution pixels. Eligibility is decided per primitive +from its command and carried to the shader, never guessed from colours. +`scaled` puts the pattern on the native pixel grid, so it reads like the +console at any internal scale and the native VRAM a game reads back is the +same at every scale. `off` is the old true-colour output (8 bits per channel, +no pattern). The software and Vulkan renderers do not dither. The pattern is applied to the source colour before semi-transparent blending; the console dithers the blended result at its final 8-to-5-bit write. Fixed- function GL blending cannot run a shader after the blend (GL 3.3 has no diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 6e98a14a..dd40e082 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -580,11 +580,11 @@ struct RuntimeConfig { // semi-transparency truncates exactly like the PS1's 5-bit blend. // Default true: PS1 colour/blend correctness. OpenGL only. bool video_accurate_blending = true; - // dithering: PS1 dither pattern on shaded/modulated draws while the - // guest enables it (GP0(E1h) bit 9). 0 "off" = true colour (default), - // 1 "on" = pattern per internal-resolution pixel, 2 "scaled" = pattern - // on the native pixel grid (the PS1 look at any scale). OpenGL only. - int video_dithering = 0; + // dithering: PS1 dithering of Gouraud-shaded and texture-modulated + // polygons while the guest sets GP0(E1h) bit 9 (hardware behaviour). + // 1 "on" (default) = pattern per internal-resolution pixel, 2 "scaled" + // = pattern on the native pixel grid, 0 "off" = true colour. OpenGL only. + int video_dithering = 1; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; double video_pgxp_depth_threshold = 4096.0; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 9be6c379..d9153cd4 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -920,11 +920,21 @@ static void c5_to_float(uint16_t c, float *r, float *g, float *b) { *r = (c & 0x1F) / 31.0f; *g = ((c >> 5) & 0x1F) / 31.0f; *b = ((c >> 10) & 0x1F) / 31.0f; } } -/* [video] dithering (see psx_dither in the shaders): 0 off = true colour (the - * historical path), 1 the PS1 4x4 pattern at internal resolution, 2 scaled to - * the native pixel grid. s_dither_bit is the last GP0(E1h) bit 9; the shaders - * only ever see s_dither_mode while the guest has dithering enabled. */ -static int s_dither_mode = 0, s_dither_bit = 0, s_dither_live = 0; +/* [video] dithering (see psx_dither in the shaders): 1 (default) the PS1 4x4 + * pattern at internal resolution, 2 scaled to the native pixel grid, 0 off = + * true colour. s_dither_bit is the last GP0(E1h) bit 9. Each primitive's + * eligibility (bit set AND Gouraud-shaded, or a texture-modulated polygon; + * never flat, raw-textured or a rectangle) is fixed when it is queued and + * rides in its vertices, so bit changes need no flush. s_prim_shaded / + * s_prim_rect are set by the glb_draw_* entry points for gpu_geometry and + * gpu_textured_triangle. */ +static int s_dither_mode = 1, s_dither_bit = 0; +static int s_prim_shaded = 0, s_prim_rect = 0; +/* Added to a geometry vertex's a_col.a (as 2*131072) for an eligible prim. */ +#define GPU_GEO_DITHER_CODE 262144.0f +static float geo_dither_code(void) { + return (s_dither_mode && s_dither_bit && s_prim_shaded) ? GPU_GEO_DITHER_CODE : 0.0f; +} /* Opaque textured draws carry the exact mask bit in FBO alpha. Keeping the * duplicate stencil copy current is deferred until mask checking is requested. */ static int s_stencil_valid = 1; @@ -1818,21 +1828,21 @@ static const char *INTERP_FS = * rect coverage exactly [x*S, (x+w)*S) at every scale AND makes the * top-left subpixel of each S*S block sample the exact PS1 value (which is * what the PACK pass reads back). */ -/* PS1 dithering ([video] dithering). The GPU adds the 4x4 offset matrix below - * to a shaded or texture-modulated pixel before truncating it to 5 bits, when - * GP0(E1h) bit 9 is set. Flat and raw-texture pixels are already 5-bit exact - * and are left alone, which is what the hardware's shading/modulation rule - * amounts to. u_dither 0 = off (true colour, the default and the historical - * renderer), 1 = pattern per internal-resolution pixel, 2 = pattern scaled - * to the native grid (v_npos is the fragment's native VRAM position). */ +/* PS1 dithering ([video] dithering, on by default). While GP0(E1h) bit 9 is + * set the GPU adds the 4x4 offset matrix below to every pixel of a + * Gouraud-shaded polygon or line and of a texture-modulated polygon, then + * truncates to 5 bits. Flat untextured primitives, raw-texture polygons and + * all rectangles (sprites) are never dithered. Eligibility is decided per + * primitive on the CPU and carried in the vertex (v_dith), never inferred + * from the colour. u_dither 0 = off (true colour), 1 = PS1 pattern per + * internal-resolution pixel (default), 2 = pattern scaled to the native + * grid (v_npos is the fragment's native VRAM position). The result is + * written in the surface's colour basis (u_accurate). */ #define PSX_DITHER_GLSL \ "uniform int u_dither;\n" \ "uniform int u_accurate; /* [video] accurate_blending: VRAM in the k*8/255 basis */\n" \ - "vec3 psx_dither(vec3 c, vec2 npos){\n" \ - " if (u_dither == 0) return c;\n" \ - " vec3 q8 = c * (255.0 / 8.0), q31 = c * 31.0;\n" \ - " if (all(lessThan(abs(q8 - floor(q8 + 0.5)), vec3(0.02))) ||\n" \ - " (u_accurate == 0 && all(lessThan(abs(q31 - floor(q31 + 0.5)), vec3(0.02))))) return c;\n" \ + "vec3 psx_dither(vec3 c, vec2 npos, int eligible){\n" \ + " if (u_dither == 0 || eligible == 0) return c;\n" \ " ivec2 p = (u_dither == 2 ? ivec2(floor(npos)) : ivec2(gl_FragCoord.xy)) & 3;\n" \ " const float m[16] = float[16](-4.0,0.0,-3.0,1.0, 2.0,-2.0,3.0,-1.0,\n" \ " -3.0,1.0,-4.0,0.0, 3.0,-1.0,2.0,-2.0);\n" \ @@ -1853,6 +1863,7 @@ static const char *GEO_VS = "smooth out vec3 v_col_p; /* perspective-correct colour (PGXP, G1.14) */\n" "flat out int v_cp;\n" "noperspective out vec2 v_npos; /* native VRAM position (dithering) */\n" + "flat out int v_dith; /* this primitive is dither-eligible */\n" "void main(){\n" " v_npos = a_pos;\n" " /* a_col.a carries the mask bit (0/1) and, for a PGXP 3D vertex, its\n" @@ -1860,6 +1871,8 @@ static const char *GEO_VS = " * negative a is a depth-clear vertex (beyond every SZ, inside the far plane). */\n" " float a = a_col.a, code = floor(a * 0.5), zn = 0.0, w = 1.0;\n" " float m = a - 2.0 * code;\n" + " /* + 2*131072: the primitive is dither-eligible (GPU_GEO_DITHER_CODE). */\n" + " float dth = (a >= 0.0 && code >= 131072.0) ? 1.0 : 0.0; code -= 131072.0 * dth; v_dith = int(dth);\n" " float cp = code >= 65536.0 ? 1.0 : 0.0, sz = code - 65536.0 * cp;\n" " if (a < 0.0) { m = 0.0; zn = 0.9999; sz = 0.0; cp = 0.0; } /* inside the far plane: never clipped */\n" " else if (sz > 0.5) { zn = 1.0 - 512.0 / (max(sz * (1.0 - u_zbias) - (u_zbias > 0.0 ? 48.0 : 0.0), 1.0) + 256.0); if (cp > 0.5) w = sz / 1024.0; }\n" @@ -1874,10 +1887,10 @@ static const char *GEO_VS = static const char *GEO_FS = "#version 330\n" "noperspective in vec4 v_col; smooth in vec3 v_col_p; flat in int v_cp; out vec4 frag;\n" - "noperspective in vec2 v_npos;\n" + "noperspective in vec2 v_npos; flat in int v_dith;\n" PSX_DITHER_GLSL "void main(){ frag = v_cp != 0 ? vec4(v_col_p, v_col.a) : v_col;\n" - " frag.rgb = psx_dither(frag.rgb, v_npos); }\n"; + " frag.rgb = psx_dither(frag.rgb, v_npos, v_dith); }\n"; /* Textured prims: sample raw 1555 VRAM (integer), CLUT decode per depth, * texture window, optional bilinear, texel-0 discard, STP-split discard, @@ -1919,6 +1932,7 @@ static const char *TEX_VS = "flat out int v_persp;\n" "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" + "flat out int v_dith;\n" "flat out int v_twin;\n" "flat out vec4 v_hd_source; flat out int v_hd_mode;\n" "flat out int v_filter;\n" @@ -1930,6 +1944,7 @@ static const char *TEX_VS = " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" " v_semi = int(a_semi + 0.5);\n" + " v_dith = v_semi >> 3; v_semi &= 7; /* +8: dither-eligible primitive */\n" " v_twin = int(a_twin + 0.5);\n" " v_filter = int(a_filter + 0.5);\n" " v_hd_source=a_hd_source; v_hd_mode=int(a_hd_mode+0.5);\n" @@ -1970,6 +1985,7 @@ static const char *TEX_FS = "flat in int v_raw; /* 1 = no color modulation */\n" "flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n" "flat in int v_semi; /* GP0 command has semi-transparency enabled */\n" + "flat in int v_dith; /* dither-eligible (modulated polygon, E1 bit 9) */\n" "flat in int v_twin; /* texture window, GP0(E2h) bits 0..19 */\n" "flat in int v_filter; /* sampling mode belongs to this primitive */\n" "flat in vec4 v_hd_source; flat in int v_hd_mode;\n" @@ -2193,7 +2209,8 @@ static const char *TEX_FS = " if ((v_filter & 16) != 0 && v_hd_mode == 0) rgb = lod_sample(uv0, dx0, dy0, stp, rgb);\n" " if (u_semipass == 1 && stp == 1) discard;\n" " if (u_semipass == 2 && stp == 0) discard;\n" - " if (v_raw == 0) rgb = psx_dither(clamp(rgb * (v_cp != 0 ? v_col_p : v_col.rgb) * 2.0, 0.0, 1.0), v_npos);\n" + " if (v_raw == 0) rgb = clamp(rgb * (v_cp != 0 ? v_col_p : v_col.rgb) * 2.0, 0.0, 1.0);\n" + " rgb = psx_dither(rgb, v_npos, v_dith);\n" " float dst_factor = 0.0;\n" " if (u_semimode == 4 && v_semi != 0 && stp != 0) {\n" " dst_factor = v_semi == 1 ? 0.5 : 1.0;\n" @@ -4454,7 +4471,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, s_fb_depth = 0; /* lines are 2D: never depth-tested (G1.14) */ s_fb_semi = semi; s_fb_mask = (int)s_mask_set; - float mask_a = s_mask_set ? 1.0f : 0.0f; + float mask_a = (s_mask_set ? 1.0f : 0.0f) + geo_dither_code(); for (int i = 0; i < 2; i++) { float *v = &s_fb[s_fb_n * 6]; v[0] = (float)xs[i]; @@ -4481,7 +4498,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, * batch above. */ if (mode == GL_LINES && n == 2 && s_out_scale > 1 && !bd_prim_gate(xs, n, 0)) { float lv[2 * 6], quad[6 * 6]; - float mask_a = s_mask_set ? 1.0f : 0.0f; + float mask_a = (s_mask_set ? 1.0f : 0.0f) + geo_dither_code(); for (int i = 0; i < 2; i++) { lv[i*6+0] = (float)xs[i]; lv[i*6+1] = (float)ys[i]; @@ -4514,7 +4531,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, if (mode != GL_TRIANGLES || n < 3) { flush_flat_batch(); float verts[3 * 6]; - float mask_a = s_mask_set ? 1.0f : 0.0f; + float mask_a = (s_mask_set ? 1.0f : 0.0f) + geo_dither_code(); for (int i = 0; i < n; i++) { verts[i*6+0] = precise ? s_pc_x[i] : (float)xs[i]; verts[i*6+1] = precise ? s_pc_y[i] : (float)ys[i]; @@ -4596,7 +4613,7 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, s_fb_gate = gate; s_fb_depth = dmode; - float mask_a = s_mask_set ? 1.0f : 0.0f; + float mask_a = (s_mask_set ? 1.0f : 0.0f) + geo_dither_code(); float *v0 = &s_fb[s_fb_n * 6]; for (int i = 0; i < n; i++) { float *v = &s_fb[s_fb_n * 6]; @@ -4665,6 +4682,9 @@ static void gpu_textured_triangle(const int *xs, const int *ys, const float *col, uint16_t texpage, uint16_t clut_x, uint16_t clut_y, int rawtex, int semi, const int *lim) { + /* PS1 dithering: texture-modulated polygons only (never raw texels or + * rectangles), while E1 bit 9 is set. */ + const int dither_tex = s_dither_mode && s_dither_bit && !rawtex && !s_prim_rect; // Mode 2 needs proven world geometry. Sprites, HUD and untracked packets // retain point sampling; their cutout pixels must stay sharp. const int untracked=(lim && !s_projected_uv_valid) || (!s_pc_valid && !s_pq_valid); @@ -4770,7 +4790,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[12] = (float)depth; vp[13] = (float)rawtex; /* a_depth, a_raw */ vp[14] = (float)lim[0]; vp[15] = (float)lim[1]; /* a_limits */ vp[16] = (float)lim[2]; vp[17] = (float)lim[3]; - vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ + vp[18] = (semi >= 0 ? (float)(semi + 1) : 0.0f) /* a_semi code */ + + (dither_tex ? 8.0f : 0.0f); /* + dither-eligible */ vp[19] = s_pq_valid ? s_pq[i] : 0.0f; /* a_q; 0 = affine */ vp[20] = twin; /* a_twin */ vp[21] = (float)hd.origin_u; vp[22] = (float)hd.origin_v; @@ -5089,36 +5110,27 @@ void gl_renderer_set_accurate_blending(int on) { if (s_ctx) accurate_apply_uniforms(); } int gl_renderer_accurate_blending(void) { return s_accurate_blend; } -/* Dither uniforms live in the geometry and textured programs. The effective - * value only changes between batches (glb_set_dither drains them first). */ +/* Dither mode uniform in the geometry and textured programs; queued draws + * are drained before it changes. */ static GLint s_geo_uDither = -1, s_tex_uDither = -1; static void dither_apply_uniforms(void) { if (!s_geo_prog || !s_tex_prog) return; GLint prev = 0; glGetIntegerv(GL_CURRENT_PROGRAM, &prev); if (s_geo_uDither < 0) s_geo_uDither = p_glGetUniformLocation(s_geo_prog, "u_dither"); if (s_tex_uDither < 0) s_tex_uDither = p_glGetUniformLocation(s_tex_prog, "u_dither"); - p_glUseProgram(s_geo_prog); p_glUniform1i(s_geo_uDither, s_dither_live); - p_glUseProgram(s_tex_prog); p_glUniform1i(s_tex_uDither, s_dither_live); + p_glUseProgram(s_geo_prog); p_glUniform1i(s_geo_uDither, s_dither_mode); + p_glUseProgram(s_tex_prog); p_glUniform1i(s_tex_uDither, s_dither_mode); p_glUseProgram((GLuint)prev); } -static void dither_update(void) { - const int live = s_dither_bit ? s_dither_mode : 0; - if (live == s_dither_live) return; - /* Queued draws keep the state they were submitted under. */ - flush_flat_batch(); flush_tex_batch(); hiw_flush_queue(); - s_dither_live = live; - dither_apply_uniforms(); -} -/* GP0(E1h) bit 9. With [video] dithering off (the default) this only records - * the bit; nothing is flushed and no uniform changes. */ -static void glb_set_dither(int on) { - s_dither_bit = on ? 1 : 0; - if (s_dither_mode || s_dither_live) dither_update(); -} +/* GP0(E1h) bit 9: recorded; it is read when each primitive is queued. */ +static void glb_set_dither(int on) { s_dither_bit = on ? 1 : 0; } void gl_renderer_set_dithering(int mode) { GL_RT_SYNC("dithering"); - s_dither_mode = (mode == 1 || mode == 2) ? mode : 0; - if (s_ctx) dither_update(); + mode = (mode == 1 || mode == 2) ? mode : 0; + if (mode == s_dither_mode) return; + if (s_ctx) { flush_flat_batch(); flush_tex_batch(); hiw_flush_queue(); } + s_dither_mode = mode; + if (s_ctx) dither_apply_uniforms(); } int gl_renderer_dithering(void) { return s_dither_mode; } static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 3 ? b : 0; sw_set_texture_filter(b != 0); } @@ -5281,7 +5293,9 @@ static void glb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,ui native_draw_end(faithful); } if (!s_raster_ok) return; + s_prim_shaded = 1; /* Gouraud: dither-eligible */ gpu_triangle(x0,y0,c0, x1,y1,c1, x2,y2,c2, s_semi_en?s_semi_mode:-1); + s_prim_shaded = 0; precise_consumed(); } static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){ @@ -5353,7 +5367,7 @@ static void glb_draw_textured_rect(int x,int y,int w,int h,int u,int v,uint16_t if (native_required && !s_hd_native_authority) sw_draw_textured_rect(x,y,w,h,u,v,cx,cy,tp); if (s_raster_ok) - gpu_textured_rect(x,y,w,h, u,v, u+w,v+h, cx,cy,tp, s_semi_en?s_semi_mode:-1); + { s_prim_rect = 1; gpu_textured_rect(x,y,w,h, u,v, u+w,v+h, cx,cy,tp, s_semi_en?s_semi_mode:-1); s_prim_rect = 0; } if (native_required && s_hd_native_authority) { int faithful = native_draw_begin(); sw_draw_textured_rect(x,y,w,h,u,v,cx,cy,tp); @@ -5365,7 +5379,7 @@ static void glb_draw_textured_rect_scaled(int x,int y,int w,int h,int u0,int v0, if (native_required && !s_hd_native_authority) sw_draw_textured_rect_scaled(x,y,w,h,u0,v0,u1,v1,cx,cy,tp); if (s_raster_ok) - gpu_textured_rect(x,y,w,h, u0,v0, u1,v1, cx,cy,tp, s_semi_en?s_semi_mode:-1); + { s_prim_rect = 1; gpu_textured_rect(x,y,w,h, u0,v0, u1,v1, cx,cy,tp, s_semi_en?s_semi_mode:-1); s_prim_rect = 0; } if (native_required && s_hd_native_authority) { int faithful = native_draw_begin(); sw_draw_textured_rect_scaled(x,y,w,h,u0,v0,u1,v1,cx,cy,tp); @@ -5388,7 +5402,9 @@ static void glb_draw_shaded_line(int x0,int y0,uint16_t c0,int x1,int y1,uint16_ native_draw_end(faithful); } if (!s_raster_ok) return; + s_prim_shaded = 1; /* shaded line: dither-eligible */ gpu_line(x0,y0,c0, x1,y1,c1, s_semi_en?s_semi_mode:-1); + s_prim_shaded = 0; } static int gl_read_display_argb(int x, int y, int w, int h, uint32_t *out, int pitch_bytes, int cap_px, int *ow, int *oh); @@ -6036,7 +6052,6 @@ static int init_gpu_raster(void) { s_shift_hi = 0.5f / (float)s_out_scale - 1.0f / 64.0f; s_geo_uShift = p_glGetUniformLocation(s_geo_prog, "u_shift"); s_geo_uDither = s_tex_uDither = -1; - s_dither_live = s_dither_bit ? s_dither_mode : 0; dither_apply_uniforms(); s_tex_uShift = p_glGetUniformLocation(s_tex_prog, "u_shift"); s_geo_uZbias = p_glGetUniformLocation(s_geo_prog, "u_zbias"); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9ec8d55b..9c96fa81 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1294,7 +1294,7 @@ static int g_video_fmv_chroma = 0; /* [video] fmv_chroma_smoothing / /* [video] texture_lod / anisotropic_filtering; PSX_TEXTURE_LOD / PSX_ANISO. */ static int g_video_texture_lod = 0, g_video_aniso = 1; static int g_video_accurate_blending = 1; /* [video] accurate_blending / PSX_ACCURATE_BLENDING */ -static int g_video_dithering = 0; /* [video] dithering / PSX_DITHERING */ +static int g_video_dithering = 1; /* [video] dithering / PSX_DITHERING */ static int g_video_pgxp_color_correction = 0; static int g_video_pgxp_seam = 0; static float g_video_pgxp_depth_threshold = 4096.0f; diff --git a/runtime/tests/test_gl_dithering.c b/runtime/tests/test_gl_dithering.c index 92245675..048e3d6f 100644 --- a/runtime/tests/test_gl_dithering.c +++ b/runtime/tests/test_gl_dithering.c @@ -1,18 +1,22 @@ -/* [video] dithering on a real, hidden OpenGL context, driven through the - * renderer facade (gr_*) the way gpu.c drives it. +/* PS1 dithering ([video] dithering, on by default) on a real, hidden OpenGL + * context, driven through the renderer facade (gr_*) the way gpu.c drives it. * - * - off (the default): the GP0(E1h) dither bit changes nothing, so the - * native VRAM and the frame at internal resolution are byte-identical - * with the bit set or clear; - * - on / scaled with the bit clear: identical to off; - * - on / scaled with the bit set: a gouraud gradient and a modulated - * texture gain the PS1 4x4 pattern (more 5-bit steps along a row, same - * mean brightness), while flat fills, flat polygons and raw textures stay - * exactly as they were; - * - "scaled" puts the pattern on the native grid: the native VRAM at 4x - * (stepped by dynamic resolution) equals the native VRAM at 1x; - * - the render thread (records replayed later), PGXP depth and native-wide - * margins give the same pixels as the direct path. + * Eligibility, with GP0(E1h) bit 9 set (hardware rule): + * - Gouraud polygon .................. dithered (also when its three + * vertex colours are equal and 5-bit exact: eligibility is the command, + * not the colour) + * - shaded line ...................... dithered + * - texture-modulated polygon ........ dithered (also at the neutral + * 0x80 modulation, whose output is 5-bit exact) + * - flat polygon, flat line, flat rect, raw-texture polygon, + * textured rectangle (modulated or not) ...... never dithered + * With the bit clear nothing is dithered, and "off" is the true-colour + * output whatever the bit. Each eligible case is compared against "off" + * region by region in the native VRAM. + * Also: "scaled" puts the pattern on the native grid (native VRAM at 4x, + * stepped by dynamic resolution, equals 1x); the render thread, PGXP depth + * and native-wide margins give the same pixels; with accurate_blending the + * dithered channels stay on the k*8 grid. * Original source-owned scene; no retail payload. */ #define main rt_fixture_main #include "test_gl_render_thread.c" @@ -24,6 +28,31 @@ static uint16_t nat[1024 * 512]; static uint32_t hi[DW * 4 * DH * 4]; static int k_test_scale = 4; +/* Regions (native px): x, y, w, h */ +enum { R_GOURAUD, R_GOURAUD_FLATCOL, R_SHADED_LINE, R_TEX_MOD, R_TEX_NEUTRAL, + R_FLAT_TRI, R_FLAT_RECT, R_FLAT_LINE, R_TEX_RAW, R_RECT_MOD, R_RECT_RAW, R_N }; +static const int REG[R_N][4] = { + {0, 0, 160, 40}, /* Gouraud gradient */ + {170, 0, 60, 40}, /* Gouraud, equal 5-bit-exact vertex colours */ + {240, 0, 70, 40}, /* shaded lines */ + {0, 50, 70, 60}, /* modulated textured polygon */ + {80, 50, 70, 60}, /* textured polygon at neutral 0x80 modulation */ + {160, 50, 60, 40}, /* flat triangle */ + {230, 50, 60, 40}, /* flat rect */ + {0, 120, 70, 30}, /* flat lines */ + {80, 120, 70, 60}, /* raw textured polygon */ + {160, 120, 64, 64}, /* modulated textured rect */ + {240, 120, 64, 64}, /* raw textured rect */ +}; +static const char *RNAME[R_N] = {"gouraud", "gouraud equal colours", "shaded line", + "modulated textured poly", "neutral-modulated textured poly", "flat triangle", + "flat rect", "flat line", "raw textured poly", "modulated textured rect", "raw textured rect"}; +static const int ELIGIBLE[R_N] = {1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0}; + +static void tquad(int x, int y, int w, int h, uint32_t c, int raw) { + gr_draw_shaded_textured_triangle(x, y, 0, 0, c, x + w, y, 63, 0, c, x, y + h, 0, 63, c, 512, 256, 0x0008, raw); + gr_draw_shaded_textured_triangle(x + w, y, 63, 0, c, x + w, y + h, 63, 63, c, x, y + h, 0, 63, c, 512, 256, 0x0008, raw); +} static void scene(int bit, int depth) { gr_set_draw_area(0, 0, DW - 1, DH - 1); gr_set_draw_offset(0, 0); @@ -34,21 +63,26 @@ static void scene(int bit, int depth) { gr_fill_rect(0, 0, DW, DH, 0x0000); gr_wide_clear(0, 0, DH, 0x0000); gr_set_dither(bit); - /* A horizontal gouraud gradient, black to white (rows 0..63), reaching - * into the left native-wide margin. */ + /* Gouraud gradient, black to white, reaching into the left wide margin. */ if (depth) gr_set_depth_triangle(1, 900.0f, 900.0f, 900.0f); - gr_draw_gouraud_triangle(-40, 0, 0x0000, 256, 0, 0x7fff, -40, 64, 0x0000); + gr_draw_gouraud_triangle(-40, 0, 0x0000, 160, 0, 0x7fff, -40, 40, 0x0000); if (depth) gr_set_depth_triangle(1, 900.0f, 900.0f, 900.0f); - gr_draw_gouraud_triangle(256, 0, 0x7fff, 256, 64, 0x7fff, -40, 64, 0x0000); - /* Flat polygon and flat rect (never dithered). */ - gr_draw_flat_rect(0, 80, 64, 32, 0x4210); - gr_draw_flat_triangle(80, 80, 140, 80, 80, 112, 0x2d6b); - /* Modulated and raw textured draws. */ + gr_draw_gouraud_triangle(160, 0, 0x7fff, 160, 40, 0x7fff, -40, 40, 0x0000); + /* Gouraud with three equal 5-bit-exact colours. */ + gr_draw_gouraud_triangle(170, 0, 0x4210, 230, 0, 0x4210, 170, 40, 0x4210); + gr_draw_gouraud_triangle(230, 0, 0x4210, 230, 40, 0x4210, 170, 40, 0x4210); + for (int i = 0; i < 40; i += 2) gr_draw_shaded_line(240, i, 0x0421, 309, i, 0x5ef7); gr_set_color_modulation(128, 128, 128, 0); - gr_draw_shaded_textured_triangle(160, 80, 0, 0, 0x404040, 300, 80, 63, 0, 0xa0a0a0, - 160, 200, 0, 63, 0x606060, 512, 256, 0x0008, 0); + tquad(0, 50, 70, 60, 0x6080a0, 0); + tquad(80, 50, 70, 60, 0x808080, 0); + gr_draw_flat_triangle(160, 50, 220, 50, 160, 90, 0x2d6b); + gr_draw_flat_rect(230, 50, 60, 40, 0x4210); + for (int i = 0; i < 30; i += 2) gr_draw_line(0, 120 + i, 69, 120 + i, 0x5294); + tquad(80, 120, 70, 60, 0x6080a0, 1); + gr_set_color_modulation(0x60, 0x80, 0xa0, 0); + gr_draw_textured_rect(160, 120, 64, 64, 0, 0, 512, 256, 0x0008); gr_set_color_modulation(128, 128, 128, 1); - gr_draw_textured_rect(0, 130, 64, 64, 0, 0, 512, 256, 0x0008); + gr_draw_textured_rect(240, 120, 64, 64, 0, 0, 512, 256, 0x0008); gr_set_color_modulation(128, 128, 128, 0); gr_set_dither(0); gl_renderer_render_thread_frame_boundary(); @@ -69,19 +103,19 @@ static Shot shot(void) { return s; } static int same(Shot a, Shot b) { return a.native == b.native && a.hires == b.hires && a.wide == b.wide; } -static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { - for (int r = y; r < y + h; r++) - if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; +static int region_equal(const uint16_t *a, const uint16_t *b, int r) { + const int x = REG[r][0], y = REG[r][1], w = REG[r][2], h = REG[r][3]; + for (int yy = y; yy < y + h; yy++) + if (memcmp(a + yy * 1024 + x, b + yy * 1024 + x, (size_t)w * 2)) return 0; return 1; } -/* Along the gradient's row 30: steps between neighbours, and the mean red. */ -static void row_stats(const uint16_t *v, int *steps, double *mean) { - int s = 0; long sum = 0; - for (int x = 0; x < 250; x++) { - int r = v[30 * 1024 + x] & 31, n = v[30 * 1024 + x + 1] & 31; - s += r != n; sum += r; - } - *steps = s; *mean = (double)sum / 250.0; +static double region_mean(const uint16_t *v, int r) { + long s = 0; int n = 0; + for (int y = REG[r][1]; y < REG[r][1] + REG[r][3]; y++) + for (int x = REG[r][0]; x < REG[r][0] + REG[r][2]; x++) { + uint16_t p = v[y * 1024 + x]; s += (p & 31) + ((p >> 5) & 31) + ((p >> 10) & 31); n += 3; + } + return (double)s / n; } int main(void) { @@ -105,76 +139,73 @@ int main(void) { gr_wide_configure(426, 53); static uint16_t ref[1024 * 512], dith[1024 * 512]; - /* Off: the bit is ignored. */ - check(gl_renderer_dithering() == 0, "off by default"); + check(gl_renderer_dithering() == 1, "on by default"); + /* Off: true colour whatever the bit. */ + gl_renderer_set_dithering(0); scene(0, 0); Shot off0 = shot(); memcpy(ref, nat, sizeof ref); scene(1, 0); Shot off1 = shot(); check(same(off0, off1), "off: dither bit changes nothing"); for (int mode = 1; mode <= 2; mode++) { gl_renderer_set_dithering(mode); scene(0, 0); Shot clear = shot(); - check(same(off0, clear), "on/scaled with the bit clear equals off"); + check(same(off0, clear), "bit clear: nothing is dithered"); scene(1, 0); Shot on = shot(); - check(on.native != off0.native && on.hires != off0.hires, "bit set dithers"); - check(on.wide != off0.wide, "native-wide margin dithers too"); memcpy(dith, nat, sizeof dith); - check(region_equal(ref, dith, 0, 80, 150, 32), "flat polygons untouched"); - check(region_equal(ref, dith, 0, 130, 64, 64), "raw texture untouched"); - check(!region_equal(ref, dith, 160, 80, 140, 120), "modulated texture dithered"); - int s0, s1; double m0, m1; - row_stats(ref, &s0, &m0); row_stats(dith, &s1, &m1); - printf("mode=%d steps true=%d dithered=%d mean true=%.2f dithered=%.2f\n", mode, s0, s1, m0, m1); - /* "on" dithers per internal pixel, so the native pack (each block's - * top-left sample) sees one pattern column; "scaled" all four. */ - check(mode == 2 ? s1 > s0 * 3 : s1 > s0, "dither pattern adds 5-bit steps"); - check(fabs(m1 - m0) < 1.0, "dither keeps the mean brightness"); - for (int x = 0; x < 250; x++) { - uint16_t p = dith[30 * 1024 + x]; - check((p & 31) == ((p >> 5) & 31) && (p & 31) == ((p >> 10) & 31), "grey stays grey"); + check(on.wide != off0.wide, "native-wide margin dithers too"); + for (int r = 0; r < R_N; r++) { + const int eq = region_equal(ref, dith, r); + const double m0 = region_mean(ref, r), m1 = region_mean(dith, r); + printf("mode=%d %-32s %s mean %.2f -> %.2f\n", mode, RNAME[r], eq ? "unchanged" : "dithered", m0, m1); + char msg[128]; + snprintf(msg, sizeof msg, "%s: %s", RNAME[r], ELIGIBLE[r] ? "dithered" : "never dithered"); + /* "on" at 4x: the native pack sees the top-left sample of each + * 4x4 block, i.e. one pattern cell (-4), still a change. */ + check(ELIGIBLE[r] ? !eq : eq, msg); + /* Mean over the native pack: "on" at 4x reads one pattern cell + * per block, "scaled" the whole pattern. */ + if (ELIGIBLE[r] && mode == 2) check(fabs(m1 - m0) < 1.0, "dither keeps the mean brightness"); } - /* PGXP depth: the same 2D-depth scene, depth-tested. */ + /* PGXP depth: the same scene, depth-tested. */ gl_renderer_set_pgxp_depth(1); scene(1, 1); Shot dep = shot(); check(dep.native == on.native, "PGXP depth path dithers the same"); gl_renderer_set_pgxp_depth(0); - /* Render thread: dither bit and mode recorded with the draws. */ + /* Render thread: bit, mode and per-primitive eligibility recorded. */ check(gl_renderer_render_thread_start(2) == 1, "render thread started"); scene(1, 0); gl_renderer_render_thread_sync("dither"); Shot rt = shot(); gl_renderer_render_thread_stop(); check(same(rt, on), "render thread gives the same pixels"); - /* With accurate_blending the dither output uses the k*8 basis, like - * every other write: every gradient channel at internal resolution - * is a multiple of 8 (no k*255/31 values mixed in). */ + /* With accurate_blending every dithered gradient channel is on the + * k*8 grid (the surface basis). */ + const int acc0 = gl_renderer_accurate_blending(); gl_renderer_set_accurate_blending(1); - scene(1, 0); Shot acc = shot(); + scene(1, 0); (void)shot(); int off_basis = 0; - for (int y = 0; y < 60 * gr_scale(); y++) - for (int x = 0; x < 250 * gr_scale(); x++) { - uint32_t p = hi[y * DW * gr_scale() + x]; - off_basis += ((p & 0xff) % 8) != 0; - } - int sa; double ma; - row_stats(nat, &sa, &ma); - printf("mode=%d accurate: mean=%.2f channels off the k*8 grid=%d\n", mode, ma, off_basis); - check(fabs(ma - m0) < 1.0, "dither + accurate blending keeps the mean"); + for (int y = 0; y < 40 * gr_scale(); y++) + for (int x = 0; x < 150 * gr_scale(); x++) + off_basis += ((hi[y * DW * gr_scale() + x] & 0xff) % 8) != 0; + printf("mode=%d accurate: gradient channels off the k*8 grid=%d\n", mode, off_basis); check(off_basis == 0, "dither output follows the accurate (k*8) basis"); - gl_renderer_set_accurate_blending(0); + gl_renderer_set_accurate_blending(acc0); if (mode == 2) { - /* Scaled: the pattern sits on the native grid at any scale. */ check(gl_renderer_step_internal_scale_now(1) == 1, "step to 1x"); scene(1, 0); Shot one = shot(); - check(one.native == on.native, "scaled: native VRAM at 4x equals 1x"); + (void)one; + /* Lines are drawn as one-native-pixel quads above 1x (see + * test_gl_scale_invariance.c), so they are not compared. */ + int inv = 1; + for (int r = 0; r < R_N; r++) + if (r != R_SHADED_LINE && r != R_FLAT_LINE) inv &= region_equal(dith, nat, r); + check(inv, "scaled: native VRAM at 4x equals 1x"); check(gl_renderer_step_internal_scale_now(k_test_scale) == 1, "back to 4x"); } } gl_renderer_set_dithering(0); scene(1, 0); Shot back = shot(); - check(same(back, off0), "back off restores the exact true-colour image"); + check(same(back, off0), "off again restores the exact true-colour image"); check(glGetError() == GL_NO_ERROR, "GL errors"); - printf("off native=%016llx hires=%016llx\n", (unsigned long long)off0.native, - (unsigned long long)off0.hires); printf("checks=%d failures=%d\n", checks, failures); gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); return failures ? 1 : 0; From de8d28618000bfcea0a979a1f9b9cc6369a52a41 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:42:27 -0700 Subject: [PATCH 15/17] Dithering: native-grid pattern is the default, internal-pixel is elective Review of #584: the default must not make guest-visible VRAM depend on the internal scale. Mode 1 ("on", default; "scaled" kept as an alias) now puts the PS1 pattern on the native VRAM grid; mode 2 ("fine") is the elective per-internal-pixel pattern. gl_dithering_test checks the default mode's native VRAM is identical at 4x and 1x (equal-colour Gouraud now averages 15.50 in the default at every scale). Docs keep the semi-transparent approximation explicit. OpenBIOS stamp regenerated. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 2 +- docs/config_schema.md | 37 +++++++++++++++++-------------- recompiler/src/config_loader.cpp | 6 ++--- recompiler/src/config_loader.h | 5 +++-- runtime/include/gpu_gl_renderer.h | 7 +++--- runtime/src/debug_server.c | 2 +- runtime/src/gpu_gl_renderer.c | 15 +++++++------ runtime/tests/test_gl_dithering.c | 17 +++++++------- 8 files changed, 49 insertions(+), 42 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 33d51086..cf46a799 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -62,7 +62,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `fmv_chroma` | ✓ | | `on` (optional: 0/1) | Read or set `[video] fmv_chroma_smoothing` live. Replies `on` and `frames` (24-bit presents smoothed so far) | | `texture_lod` | ✓ | | `mode` (optional: 0 off, 1 mipmap), `aniso` (optional: 1..16) | Read or set `[video] texture_lod` / `anisotropic_filtering` live. Replies `mode`, `aniso` | -| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on (default), 2 scaled) | Read or set `[video] dithering` live. Replies `mode` | +| `dithering` | ✓ | | `mode` (optional: 0 off, 1 on (default, native grid), 2 fine (internal pixels)) | Read or set `[video] dithering` live. Replies `mode` | | `post_aa` | ✓ | | `mode` (optional: 0 off, 1 fxaa, 2 fxaa_hq) | Read or set post-process anti-aliasing live (`[video] antialiasing_mode`). Replies `mode`, `passes` (filtered presents so far) and `gpu_us`: the mean pass time since the last query when the run has `PSX_POST_AA_TIME=N` (N passes per present between `glFinish` fences, a measurement mode), else 0 | | `hd_textures` | ✓ | | optional `replacements`, `dump` (0 or 1), `reload` (1) | HD pack root, switches, backend support, replacement count, matched/ready/applied draw counts, queued/pending dumps, composition hits/builds/pixels, GL uploads/evictions, decoded images/evictions/bytes. Optional controls update the configured host pack on the emulation thread; no configured root is an error. Reload retains unchanged upload identities. OpenGL displays replacements; software/Vulkan retain original artwork. Pair with `present_shot` for visual evidence and native VRAM probes for architectural data | | `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | diff --git a/docs/config_schema.md b/docs/config_schema.md index f47ec3ce..b6eb3b37 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -729,29 +729,32 @@ first layer's full 8-bit result where the console would have truncated it to ```toml [video] -dithering = "on" # on (default) | scaled | off (true colour) +dithering = "on" # on (default) | fine | off ``` The PS1 GPU dithers while the game sets GP0(E1h) bit 9: it adds a 4x4 offset pattern to every pixel of a Gouraud-shaded polygon or line and of a texture-modulated polygon, then truncates to 5 bits per channel. Flat untextured primitives, raw-texture polygons and all rectangles (sprites) are -never dithered. The OpenGL renderer does the same by default (`on`), with the -pattern on internal-resolution pixels. Eligibility is decided per primitive -from its command and carried to the shader, never guessed from colours. -`scaled` puts the pattern on the native pixel grid, so it reads like the -console at any internal scale and the native VRAM a game reads back is the -same at every scale. `off` is the old true-colour output (8 bits per channel, -no pattern). The software and Vulkan renderers do not dither. -The pattern is applied to the source colour before semi-transparent blending; -the console dithers the blended result at its final 8-to-5-bit write. Fixed- -function GL blending cannot run a shader after the blend (GL 3.3 has no -framebuffer fetch, and macOS offers none), so a dithered semi-transparent -pixel can differ from the console by one 5-bit step; opaque pixels match. -The dither output follows the surface basis, so it composes with -`accurate_blending`. -OpenGL only. `PSX_DITHERING=0|1|2` overrides it for one run and the -`dithering` TCP command switches it live. +never dithered. Eligibility is decided per primitive from its command and +carried to the shader, never guessed from colours. + +`on` (the default; `scaled` is accepted as an alias) is that hardware +behaviour with the pattern on the native VRAM grid, so it looks like the +console at any internal scale and the VRAM a game reads back is identical at +every scale. `fine` is elective: the pattern per internal-resolution pixel, a +grain that all but vanishes at high scales (guest VRAM then depends on the +scale). `off` is the old true-colour output (8 bits per channel, no pattern). + +Semi-transparent primitives are an approximation: the pattern is applied to +the source colour before blending, while the console dithers the blended +result at its final 8-to-5-bit write. Fixed-function GL blending cannot run +a shader after the blend (GL 3.3 has no framebuffer fetch, and macOS offers +none), so a dithered semi-transparent pixel can differ from the console by +one 5-bit step; opaque pixels match exactly. The dither output follows the +surface colour basis, so it composes with `accurate_blending`. OpenGL only; +the software and Vulkan renderers do not dither. `PSX_DITHERING=0|1|2` +overrides it for one run and the `dithering` TCP command switches it live. ### Texture filtering (`texture_filtering`) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index b7af12a6..0dc6893b 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -843,10 +843,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("dithering")) { const auto mode = toml::find(video, "dithering"); if (mode == "off") rt.video_dithering = 0; - else if (mode == "on") rt.video_dithering = 1; - else if (mode == "scaled") rt.video_dithering = 2; + else if (mode == "on" || mode == "scaled") rt.video_dithering = 1; + else if (mode == "fine") rt.video_dithering = 2; else throw std::runtime_error(fmt::format( - "[video] dithering must be \"off\", \"on\" or \"scaled\": {}", mode)); + "[video] dithering must be \"on\", \"fine\" or \"off\": {}", mode)); } if (video.contains("pgxp_depth_buffer")) rt.video_pgxp_depth_buffer = toml::find(video, "pgxp_depth_buffer"); diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index dd40e082..648711c0 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -582,8 +582,9 @@ struct RuntimeConfig { bool video_accurate_blending = true; // dithering: PS1 dithering of Gouraud-shaded and texture-modulated // polygons while the guest sets GP0(E1h) bit 9 (hardware behaviour). - // 1 "on" (default) = pattern per internal-resolution pixel, 2 "scaled" - // = pattern on the native pixel grid, 0 "off" = true colour. OpenGL only. + // 1 "on" (default; "scaled" is an alias) = pattern on the native VRAM + // grid, identical guest VRAM at every scale; 2 "fine" = elective pattern + // per internal-resolution pixel; 0 "off" = true colour. OpenGL only. int video_dithering = 1; bool video_pgxp_color_correction = false; int video_pgxp_seam = 0; diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index e87c7303..85d8dd1d 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -306,9 +306,10 @@ int gl_renderer_anisotropy(void); * uploads and untextured primitives already are). On by default. */ void gl_renderer_set_accurate_blending(int on); int gl_renderer_accurate_blending(void); -/* [video] dithering: 0 off (true colour, default), 1 PS1 dither pattern at - * internal resolution, 2 pattern scaled to the native pixel grid. Applies to - * shaded/modulated draws while the guest's GP0(E1h) dither bit is set. */ +/* [video] dithering: 1 (default) PS1 dither pattern on the native VRAM grid + * (guest VRAM identical at every internal scale), 2 elective pattern per + * internal-resolution pixel, 0 off (true colour). Applies to eligible + * primitives (Gouraud, texture-modulated polygons) while GP0(E1h) bit 9 is set. */ void gl_renderer_set_dithering(int mode); int gl_renderer_dithering(void); int gl_renderer_get_pgxp_depth(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 2c20224c..914f2538 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8650,7 +8650,7 @@ static void handle_texture_filter(int id, const char *json) } /* dithering: {"cmd":"dithering"} reads, {"cmd":"dithering","mode":0|1|2} - * sets [video] dithering live (off, on, scaled). */ + * sets [video] dithering live (0 off, 1 on = native grid, 2 fine). */ static void handle_dithering(int id, const char *json) { int mode = json_get_int(json, "mode", -1); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d9153cd4..faad9164 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -921,8 +921,8 @@ static void c5_to_float(uint16_t c, float *r, float *g, float *b) { } } /* [video] dithering (see psx_dither in the shaders): 1 (default) the PS1 4x4 - * pattern at internal resolution, 2 scaled to the native pixel grid, 0 off = - * true colour. s_dither_bit is the last GP0(E1h) bit 9. Each primitive's + * pattern on the native VRAM grid, 2 elective pattern per internal-resolution + * pixel, 0 off = true colour. s_dither_bit is the last GP0(E1h) bit 9. Each primitive's * eligibility (bit set AND Gouraud-shaded, or a texture-modulated polygon; * never flat, raw-textured or a rectangle) is fixed when it is queued and * rides in its vertices, so bit changes need no flush. s_prim_shaded / @@ -1834,16 +1834,17 @@ static const char *INTERP_FS = * truncates to 5 bits. Flat untextured primitives, raw-texture polygons and * all rectangles (sprites) are never dithered. Eligibility is decided per * primitive on the CPU and carried in the vertex (v_dith), never inferred - * from the colour. u_dither 0 = off (true colour), 1 = PS1 pattern per - * internal-resolution pixel (default), 2 = pattern scaled to the native - * grid (v_npos is the fragment's native VRAM position). The result is - * written in the surface's colour basis (u_accurate). */ + * from the colour. u_dither 1 (default) = the PS1 pattern on the native + * VRAM grid (v_npos is the fragment's native position), so guest-visible + * VRAM is the same at every internal scale; 2 = elective finer pattern per + * internal-resolution pixel; 0 = off (true colour). The result is written + * in the surface's colour basis (u_accurate). */ #define PSX_DITHER_GLSL \ "uniform int u_dither;\n" \ "uniform int u_accurate; /* [video] accurate_blending: VRAM in the k*8/255 basis */\n" \ "vec3 psx_dither(vec3 c, vec2 npos, int eligible){\n" \ " if (u_dither == 0 || eligible == 0) return c;\n" \ - " ivec2 p = (u_dither == 2 ? ivec2(floor(npos)) : ivec2(gl_FragCoord.xy)) & 3;\n" \ + " ivec2 p = (u_dither == 2 ? ivec2(gl_FragCoord.xy) : ivec2(floor(npos))) & 3;\n" \ " const float m[16] = float[16](-4.0,0.0,-3.0,1.0, 2.0,-2.0,3.0,-1.0,\n" \ " -3.0,1.0,-4.0,0.0, 3.0,-1.0,2.0,-2.0);\n" \ " vec3 k = clamp(floor((c * 255.0 + m[p.y * 4 + p.x]) / 8.0), 0.0, 31.0);\n" \ diff --git a/runtime/tests/test_gl_dithering.c b/runtime/tests/test_gl_dithering.c index 048e3d6f..4cfd1a7f 100644 --- a/runtime/tests/test_gl_dithering.c +++ b/runtime/tests/test_gl_dithering.c @@ -13,8 +13,9 @@ * With the bit clear nothing is dithered, and "off" is the true-colour * output whatever the bit. Each eligible case is compared against "off" * region by region in the native VRAM. - * Also: "scaled" puts the pattern on the native grid (native VRAM at 4x, - * stepped by dynamic resolution, equals 1x); the render thread, PGXP depth + * The default "on" puts the pattern on the native VRAM grid: the native VRAM + * at 4x, stepped by dynamic resolution, equals 1x (no guest-visible change + * across scales). "fine" (elective) dithers per internal pixel. Also: the render thread, PGXP depth * and native-wide margins give the same pixels; with accurate_blending the * dithered channels stay on the k*8 grid. * Original source-owned scene; no retail payload. */ @@ -158,12 +159,12 @@ int main(void) { printf("mode=%d %-32s %s mean %.2f -> %.2f\n", mode, RNAME[r], eq ? "unchanged" : "dithered", m0, m1); char msg[128]; snprintf(msg, sizeof msg, "%s: %s", RNAME[r], ELIGIBLE[r] ? "dithered" : "never dithered"); - /* "on" at 4x: the native pack sees the top-left sample of each + /* "fine" at 4x: the native pack sees the top-left sample of each * 4x4 block, i.e. one pattern cell (-4), still a change. */ check(ELIGIBLE[r] ? !eq : eq, msg); - /* Mean over the native pack: "on" at 4x reads one pattern cell - * per block, "scaled" the whole pattern. */ - if (ELIGIBLE[r] && mode == 2) check(fabs(m1 - m0) < 1.0, "dither keeps the mean brightness"); + /* Mean over the native pack: the default reads the whole pattern; + * "fine" at 4x reads one pattern cell per block. */ + if (ELIGIBLE[r] && mode == 1) check(fabs(m1 - m0) < 1.0, "dither keeps the mean brightness"); } /* PGXP depth: the same scene, depth-tested. */ gl_renderer_set_pgxp_depth(1); @@ -189,7 +190,7 @@ int main(void) { printf("mode=%d accurate: gradient channels off the k*8 grid=%d\n", mode, off_basis); check(off_basis == 0, "dither output follows the accurate (k*8) basis"); gl_renderer_set_accurate_blending(acc0); - if (mode == 2) { + if (mode == 1) { /* default: guest VRAM independent of the scale */ check(gl_renderer_step_internal_scale_now(1) == 1, "step to 1x"); scene(1, 0); Shot one = shot(); (void)one; @@ -198,7 +199,7 @@ int main(void) { int inv = 1; for (int r = 0; r < R_N; r++) if (r != R_SHADED_LINE && r != R_FLAT_LINE) inv &= region_equal(dith, nat, r); - check(inv, "scaled: native VRAM at 4x equals 1x"); + check(inv, "default dithering: native VRAM at 4x equals 1x"); check(gl_renderer_step_internal_scale_now(k_test_scale) == 1, "back to 4x"); } } From 6e27cdb1117265e612b9a4b351c9fd434cef12c3 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:20:02 -0700 Subject: [PATCH 16/17] Re-stamp OpenBIOS emitter fingerprint after rebase on master Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 2e51216e..00b438c8 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -d0e31158eda04d03cc0da52b06f7dd9c8d142f24b6e5c4aeb1442683eeb90379 +cce8aeb4725d97a4a6be909fabdf27f593c9323f4a269e09fbb753e1ae807d1d From c27abb3b05bac1249327c02fc9db7c97070d59ee Mon Sep 17 00:00:00 2001 From: Yves Date: Sat, 10 Oct 2026 01:47:29 +0800 Subject: [PATCH 17/17] VR: desktop spectator mirror (single eye, on by default) The window shows the live XR eye textures while a session runs, so the headset view can be watched on the monitor. Default is the left eye filled to the window; PSX_VR_DESKTOP_MIRROR selects 0 (keep the legacy desktop presentation, the side-by-side pair), 1 left (default), 2 side-by-side, 3 right. Uses the exact textures submitted to the runtime. --- runtime/src/gpu_gl_renderer.c | 53 +++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index c375e9ec..8efec829 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -670,6 +670,8 @@ static void pass_note_new_frame(int origin_x, int origin_y, int source_path, static void pass_apply_promotion(void); static int pass_gen_present(uint64_t deadline); static int stereo_present(int w, int h); +static int xr_mirror_mode(void); +static int xr_mirror_active(void); static void stereo_resources_release(void); static void stereo_invalidate(void); static uint64_t s_idle_ticks_accum_fwd(uint64_t add); @@ -8794,15 +8796,29 @@ static int stereo_present(int w, int h) { gl_renderer_stereo_unavailable() != PSX_MOD_RENDER_PASS_READY) return 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); gl_perf_present_enter(); - letterbox_rect_aspect(ww, wh, s_aspect_num * 2, s_aspect_den, &lx, &ly, &lw, &lh); + /* Desktop spectator layout while XR runs: default single left eye filled to + * the window, PSX_VR_DESKTOP_MIRROR selects 0 legacy / 1 left / 2 SBS / 3 right. */ + const int mirror = xr_mirror_active() ? xr_mirror_mode() : 0; + const int solo = (mirror == 1 || mirror == 3); + const int solo_eye = (mirror == 3) ? 1 : 0; + if (solo) + letterbox_rect_aspect(ww, wh, p->tw[solo_eye], p->th[solo_eye], &lx, &ly, &lw, &lh); + else + letterbox_rect_aspect(ww, wh, s_aspect_num * 2, s_aspect_den, &lx, &ly, &lw, &lh); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); - for (int eye = 0; eye < 2; eye++) { - int start = eye ? lw / 2 : 0, width = eye ? lw - lw / 2 : lw / 2; - present_target_quad(p->tex[eye], p->tw[eye], p->th[eye], - 0, 0, p->tw[eye], p->th[eye], 0, - lx + start, ly, width, lh, 1, 1, 1); + if (solo) { + present_target_quad(p->tex[solo_eye], p->tw[solo_eye], p->th[solo_eye], + 0, 0, p->tw[solo_eye], p->th[solo_eye], 0, + lx, ly, lw, lh, 1, 1, 1); + } else { + for (int eye = 0; eye < 2; eye++) { + int start = eye ? lw / 2 : 0, width = eye ? lw - lw / 2 : lw / 2; + present_target_quad(p->tex[eye], p->tw[eye], p->th[eye], + 0, 0, p->tw[eye], p->th[eye], 0, + lx + start, ly, width, lh, 1, 1, 1); + } } pres_record(GL_PRES_STEREO, p->x, p->y, w, h, lx, ly, lw, lh); hold_capture_drawable(); @@ -8817,6 +8833,31 @@ static int stereo_present(int w, int h) { return 1; } +/* ---- Desktop spectator mirror ------------------------------------------- + * stereo_present() draws the live XR eye textures, so this shows the headset + * image on the monitor. Default is a single left eye filled to the window; + * PSX_VR_DESKTOP_MIRROR selects 0 (restore the legacy desktop presentation, the + * side-by-side pair -- the window is not blanked or stopped), 1 left (default), + * 2 side-by-side, 3 right. Applies only while an XR session is running. */ +static int s_xr_mirror = -2; +static int xr_mirror_mode(void) { + if (s_xr_mirror == -2) { + const char* e = getenv("PSX_VR_DESKTOP_MIRROR"); + if (!e || !*e) s_xr_mirror = 1; /* on by default */ + else if (*e == '0') s_xr_mirror = 0; + else if (!strcmp(e, "sbs") || !strcmp(e, "both") || !strcmp(e, "2")) s_xr_mirror = 2; + else if (!strcmp(e, "right") || !strcmp(e, "r") || !strcmp(e, "3")) s_xr_mirror = 3; + else s_xr_mirror = 1; + } + return s_xr_mirror; +} +static int xr_mirror_active(void) { + if (xr_mirror_mode() == 0) return 0; + PSXOpenXRStats s; + psx_openxr_stats(&s); + return s.running ? 1 : 0; +} + /* Draw two presented images crossfaded (t = weight of b) into the window. */ static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode);