diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index c65e9c4c..989de283 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -94,8 +94,9 @@ a smoothed per-pass cost. Nothing ever slows the guest down: when the budget runs out, fewer passes are rendered. Pass images are kept at internal resolution (the size the presenter -captures); two generations fit a 256 MiB budget, which limits passes per -frame at very high internal resolutions. +captures). Their textures are made as the slots fill, never more slots than +two generations fit in 256 MiB, which limits passes per frame at very high +internal resolutions; a size change frees the old set. ## Verifying a title diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 7506c8cc..792f2945 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `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 | | `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) | -| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images | +| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images, pass image textures allocated (`image_textures`, `image_bytes`) | | `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `/g__a.png` | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 5b6f39ef..eb787047 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -64,6 +64,8 @@ void gl_renderer_pass_service_presents(void); void gl_renderer_pass_diag(uint64_t out[8]); /* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */ uint64_t gl_renderer_pass_journaled(void); +/* Pass image textures allocated now (both generations) and their bytes. */ +uint32_t gl_renderer_pass_image_textures(uint64_t *bytes); /* Debug: dump the images of the next `generations` shown frames as PNGs. */ void gl_renderer_pass_dump_arm(const char *dir, int generations); uint64_t gl_renderer_perf_ticks(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4d21f020..2aeb9337 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7980,9 +7980,11 @@ static void handle_render_pass_stats(int id, const char *json) { (void)json; RenderPassStats st; - uint64_t gd[8]; + uint64_t gd[8], image_bytes = 0; + uint32_t image_textures; render_pass_get_stats(&st); gl_renderer_pass_diag(gd); + image_textures = gl_renderer_pass_image_textures(&image_bytes); send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu," "\"wanted\":%llu,\"refused\":%llu,\"passes\":%llu," "\"aborted\":%llu,\"discarded\":%llu,\"watchdog\":%llu,\"vram_leaks\":%llu," @@ -7997,7 +7999,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"promotions\":%llu,\"pass_presents\":%llu," "\"blended_presents\":%llu,\"expired\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," - "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu}", + "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," + "\"image_textures\":%u,\"image_bytes\":%llu}", id, (unsigned long long)st.plans, (unsigned long long)st.planned, (unsigned long long)st.wanted, (unsigned long long)st.refused, (unsigned long long)st.passes, (unsigned long long)st.aborted, @@ -8018,7 +8021,8 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gd[2], (unsigned long long)gd[3], (unsigned long long)gd[4], (unsigned long long)gd[5], (unsigned long long)gd[6], (unsigned long long)gd[7], - (unsigned long long)gl_renderer_pass_journaled()); + (unsigned long long)gl_renderer_pass_journaled(), + (unsigned)image_textures, (unsigned long long)image_bytes); } /* render_pass_dump path= count=: write the images (the game's own diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 32f741d2..d9320b49 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -3212,7 +3212,10 @@ void gl_renderer_set_swap_interval(int interval) { } } +static void pass_resources_release(void); + void gl_renderer_shutdown(void) { + pass_resources_release(); if (s_ctx) { for (unsigned i = 1; i < 65536u; ++i) if (s_bank_tex[i]) glDeleteTextures(1, &s_bank_tex[i]); @@ -4335,7 +4338,11 @@ typedef struct PassGen { double t_start, t_len; /* host ticks, set on promotion */ } PassGen; static PassGen s_pgen[2]; +/* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at + * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two + * generations stay inside its budget whatever the internal scale. */ static GLuint s_pgen_tex[2][PASS_SLOTS]; +static uint32_t s_pgen_alloc_n[2]; static int s_pgen_alloc_w[2], s_pgen_alloc_h[2]; static int s_pgen_cur = 0; static int s_pgen_promote = 0; @@ -4484,17 +4491,32 @@ static int pass_make_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, return 1; } -static int pass_gen_textures(int gi, int w, int h) { - if (s_pgen_alloc_w[gi] == w && s_pgen_alloc_h[gi] == h && s_pgen_tex[gi][0]) - return 1; - for (uint32_t i = 0; i < PASS_SLOTS; i++) { - if (!s_pgen_tex[gi][i]) glGenTextures(1, &s_pgen_tex[gi][i]); - glBindTexture(GL_TEXTURE_2D, s_pgen_tex[gi][i]); +static void pass_gen_release(int gi) { + if (s_ctx && s_pgen_alloc_n[gi]) + glDeleteTextures((GLsizei)s_pgen_alloc_n[gi], s_pgen_tex[gi]); + memset(s_pgen_tex[gi], 0, sizeof s_pgen_tex[gi]); + s_pgen_alloc_n[gi] = 0; + s_pgen_alloc_w[gi] = s_pgen_alloc_h[gi] = 0; +} + +/* Make sure slots [0, need) of generation gi exist at w x h. A size change + * frees the old set first (the slot cap depends on the size only). */ +static int pass_gen_reserve(int gi, uint32_t need, int w, int h) { + if (need > PASS_SLOTS || need > pass_slot_cap(w, h)) return 0; + if (s_pgen_alloc_w[gi] != w || s_pgen_alloc_h[gi] != h) { + pass_gen_release(gi); + s_pgen_alloc_w[gi] = w; + s_pgen_alloc_h[gi] = h; + } + while (s_pgen_alloc_n[gi] < need) { + GLuint *t = &s_pgen_tex[gi][s_pgen_alloc_n[gi]]; + glGenTextures(1, t); + if (!*t) return 0; + glBindTexture(GL_TEXTURE_2D, *t); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + s_pgen_alloc_n[gi]++; } - s_pgen_alloc_w[gi] = w; - s_pgen_alloc_h[gi] = h; return 1; } @@ -4570,6 +4592,38 @@ static PassJournal s_pj[PASS_JOURNAL_MAX]; static int s_pj_n = 0; static uint64_t s_pj_total = 0; +static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, + int *w, int *h) { + if (s_ctx) { + if (*fbo) p_glDeleteFramebuffers(1, fbo); + if (*tex) glDeleteTextures(1, tex); + if (rb && *rb) p_glDeleteRenderbuffers(1, rb); + } + *fbo = 0; *tex = 0; + if (rb) *rb = 0; + *w = *h = 0; +} + +/* Context teardown (gl_renderer_shutdown): free the pass images, backups + * and journal, and forget their names so a new context makes fresh ones. */ +static void pass_resources_release(void) { + pass_gen_release(0); + pass_gen_release(1); + pass_gens_invalidate(); + pass_free_color_fbo(&s_pb_hr_tex, &s_pb_hr_rb, &s_pb_hr_fbo, + &s_pb_hr_w, &s_pb_hr_h); + pass_free_color_fbo(&s_pb_raw_tex, NULL, &s_pb_raw_fbo, + &s_pb_raw_w, &s_pb_raw_h); + pass_free_color_fbo(&s_pb_wide_tex, &s_pb_wide_rb, &s_pb_wide_fbo, + &s_pb_wide_w, &s_pb_wide_h); + for (int i = 0; i < PASS_JOURNAL_MAX; i++) { + PassJournal *e = &s_pj[i]; + pass_free_color_fbo(&e->hr_tex, &e->hr_rb, &e->hr_fbo, &e->hr_w, &e->hr_h); + pass_free_color_fbo(&e->raw_tex, NULL, &e->raw_fbo, &e->raw_w, &e->raw_h); + } + s_pj_n = 0; +} + static int pass_journal_protect(int x, int y, int w, int h) { int S = s_scale; PassJournal *j; @@ -4643,7 +4697,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, g = &s_pgen[gi]; if (open_gen) { if (tw != s_interp_w || th != s_interp_h) return 0; /* not what is presented */ - if (!pass_gen_textures(gi, tw, th)) return 0; + if (!pass_gen_reserve(gi, 1u, tw, th)) return 0; memset(g, 0, sizeof *g); g->x = x; g->y = y; g->w = w; g->h = h; g->tex_w = tw; g->tex_h = th; @@ -4718,7 +4772,8 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { flush_tex_batch(); flush_cpu_upload(); if (keep && alpha_q16 && g->valid && !g->promoted && g->n < PASS_SLOTS && - g->n < pass_slot_cap(g->tex_w, g->tex_h)) { + g->n < pass_slot_cap(g->tex_w, g->tex_h) && + pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)) { /* Passes arrive in ascending phase; keep the list sorted anyway. */ uint32_t slot = g->n, at = g->n; GLuint t; @@ -4831,6 +4886,17 @@ void gl_renderer_pass_diag(uint64_t out[8]) { uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } +uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) { + uint32_t n = s_pgen_alloc_n[0] + s_pgen_alloc_n[1]; + if (bytes) { + *bytes = 0; + for (int gi = 0; gi < 2; gi++) + *bytes += (uint64_t)s_pgen_alloc_n[gi] * (uint64_t)s_pgen_alloc_w[gi] * + (uint64_t)s_pgen_alloc_h[gi] * 4u; + } + return n; +} + static uint64_t s_idle_ticks_accum_fwd(uint64_t add) { s_idle_ticks_accum += add; return s_idle_ticks_accum; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d2cd3309..9a26f9ac 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -80,6 +80,17 @@ assert "nesting_restore(&s_ck.nest);" in b, ( "a watchdog abort longjmps past the exits of the frames it leaves: the " "restore must put the host nesting back (render_pass_abort_test)") +gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8") +b = body(gl, "static int pass_gen_reserve(int gi, uint32_t need, int w, int h) {") +assert "need > pass_slot_cap(w, h)" in b, ( + "pass image textures must stay inside the slot cap's memory budget") +assert "pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)" in gl, ( + "pass image textures are made as slots fill, not all PASS_SLOTS up front") +assert "for (uint32_t i = 0; i < PASS_SLOTS; i++) {\n if (!s_pgen_tex" not in gl, ( + "no eager allocation of every slot texture") +assert "pass_resources_release();" in body(gl, "void gl_renderer_shutdown(void) {"), ( + "pass textures die with the context: forget them at shutdown") + # Default off means no cost for other titles: the headers folded into the # overlay codegen hash (runtime/codegen_hash_sources.cmake) must not carry the # render-pass API, or every title's overlay cache and savestates would be