From 693ba28eb32e954604be7877ab59fee98e80930d Mon Sep 17 00:00:00 2001 From: Solganis Date: Tue, 13 Oct 2026 04:08:40 +0300 Subject: [PATCH 1/4] feat: gather logs and settings for a bug report from the launcher --- README.md | 1 + docs/PLAYING.md | 18 +- .../runtime/src/gpu_gl_renderer.c.patch | 82 +++++---- src/launcher/CMakeLists.txt | 22 ++- src/launcher/main.cpp | 32 +++- src/launcher/report.cpp | 160 ++++++++++++++++++ src/launcher/report.h | 32 ++++ tests/contracts/test_launcher_contract.py | 45 +++++ tests/unit/launcher_report_test.cpp | 133 +++++++++++++++ 9 files changed, 479 insertions(+), 46 deletions(-) create mode 100644 src/launcher/report.cpp create mode 100644 src/launcher/report.h create mode 100644 tests/unit/launcher_report_test.cpp diff --git a/README.md b/README.md index 2de9ef9..6dbff61 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ disc revision; the redistributable OpenBIOS backend is used for booting. - Experimental vertical mouse look and weapon aim. - In-game settings for controls, display, fullscreen, VSync, volume, and mute. - Diagnostic views and a screen recorder for reports of a fault in the picture. +- A launcher button that gathers logs and settings for a bug report. - Memory-card saves and twelve save-state slots. - Windows launcher with disc-image browsing, automatic copy/CUE setup, full-disc SHA-256 verification, the game's settings with a hint for each, diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 37fbc1d..fb18bae 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -146,6 +146,19 @@ retain your controls and display preferences. God Mode resets off on launch. Granting all weapons and psionics marks the game as cheated, including subsequent game saves. +## Reporting a problem + +Press **Report a problem** in the launcher. It gathers the game's log of the +last two runs, the mouse log, your settings and a note of the system into one +ZIP under `reports/` beside the executable and shows it in Explorer. Where +Windows cannot make the ZIP, it is a folder of the same name: attach its +files. The name of your Windows user folder is cut out of paths, and +your disc image, BIOS and saves are never +included. Attach the ZIP to a +[bug report](https://github.com/micmea668/DisruptorRecomp/issues) with the +level and the steps that lead to the problem. For a fault in the picture add +a replay capsule or a screen recording, described below. + ## Reports and replay capsules **Settings → Debug** offers diagnostic views and **Record the screen** for @@ -181,9 +194,8 @@ for the replay tool and its limits. your graphics driver. Try turning off 60 FPS gameplay and in-between frames if they are enabled. - **Mouse does not turn:** enter gameplay and middle-click to capture it. -- **Other problems:** include the level, reproduction steps, render settings, - and `startup.log` in a [bug report](https://github.com/micmea668/DisruptorRecomp/issues). - Do not attach your game image, game assets, or BIOS files. +- **Other problems:** see [Reporting a problem](#reporting-a-problem). Do not + attach your game image, game assets, or BIOS files. This is an early alpha. The first mission and training-level path have been tested, including the recent 8x / 32:9 corruption fix. Full campaign coverage diff --git a/framework/patches/runtime/src/gpu_gl_renderer.c.patch b/framework/patches/runtime/src/gpu_gl_renderer.c.patch index df656f7..e222c96 100644 --- a/framework/patches/runtime/src/gpu_gl_renderer.c.patch +++ b/framework/patches/runtime/src/gpu_gl_renderer.c.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c -index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4fbc378aee 100644 +index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54f6786455 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -67,6 +67,9 @@ @@ -1502,7 +1502,17 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f return 1; } -@@ -2467,11 +3358,60 @@ +@@ -2454,7 +3345,8 @@ + } + glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); + const char *ver = (const char *)glGetString(GL_VERSION); +- fprintf(stdout, "psxrecomp: OpenGL context created (%s)\n", ver ? ver : "?"); ++ const char *card = (const char *)glGetString(GL_RENDERER); ++ fprintf(stdout, "psxrecomp: OpenGL context created (%s, %s)\n", ver ? ver : "?", card ? card : "?"); + + /* All-or-nothing: any missing entry point / failed shader / bad FBO means + * the whole GL renderer is unavailable and the runtime stays on the pure +@@ -2467,11 +3359,60 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); s_present_prog = build_program(PRESENT_VS, PRESENT_FS); @@ -1563,7 +1573,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); -@@ -2484,6 +3424,7 @@ +@@ -2484,6 +3425,7 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); } @@ -1571,7 +1581,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f } else ok = 0; } if (ok) ok = init_gpu_raster(); -@@ -2496,20 +3437,28 @@ +@@ -2496,20 +3438,28 @@ return 1; } @@ -1613,7 +1623,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f if (s_interp_thread) { SDL_AtomicSet(&s_interp_thread_run, 0); SDL_WaitThread(s_interp_thread, NULL); -@@ -2532,12 +3481,25 @@ +@@ -2532,12 +3482,25 @@ } } ensure_cpu(); @@ -1639,7 +1649,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f free(s_conv); s_conv = NULL; s_raster_ok = 0; /* New context regenerates s_present_tex empty; a stale size makes -@@ -2549,16 +3511,36 @@ +@@ -2549,16 +3512,36 @@ rect_clear(&s_d24_skip_fb); } @@ -1677,7 +1687,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); -@@ -2608,6 +3590,8 @@ +@@ -2608,6 +3591,8 @@ p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); p_glUseProgram(0); pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); @@ -1686,7 +1696,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -2618,10 +3602,12 @@ +@@ -2618,10 +3603,12 @@ void gl_renderer_present_blank(void) { if (!s_ctx) return; interp_reset_history(); @@ -1700,7 +1710,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -2749,6 +3735,7 @@ +@@ -2749,6 +3736,7 @@ * ------------------------------------------------------------------------- */ static void wide_free_all(void) { @@ -1708,7 +1718,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f for (int i = 0; i < WIDE_MAX_SURF; i++) { if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } -@@ -2802,9 +3789,11 @@ +@@ -2802,9 +3790,11 @@ static void glb_wide_configure(int wide_w, int offset) { if (!s_raster_ok) return; double t0 = cw_ms(); s_cw_wide_cfgs++; @@ -1720,7 +1730,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f g_wide_w = wide_w; g_wide_off = offset; s_cw_wide_ms += cw_ms() - t0; -@@ -2814,6 +3803,7 @@ +@@ -2814,6 +3804,7 @@ static void glb_wide_set_target(int base_x) { if (!s_raster_ok) { g_wide_cur = 0; return; } double t0 = cw_ms(); s_cw_wide_sets++; @@ -1728,7 +1738,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f flush_tex_batch(); /* drain into the OLD target before switching */ g_wide_cur = wide_fbo_for(base_x); g_wide_cur_base = base_x; -@@ -2821,7 +3811,7 @@ +@@ -2821,7 +3812,7 @@ } /* Stop mirroring (offscreen draws that don't target a framebuffer). */ @@ -1737,7 +1747,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f /* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: -@@ -2829,9 +3819,11 @@ +@@ -2829,9 +3820,11 @@ static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { if (!s_raster_ok || s_ws_ablate == 1) return; double t0 = cw_ms(); s_cw_wide_clears++; @@ -1749,7 +1759,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f gl_perf_mirror_begin(); int H = VRAM_H * s_scale; int y0 = y * s_scale, y1 = (y + h) * s_scale; -@@ -2849,6 +3841,13 @@ +@@ -2849,6 +3842,13 @@ glClearColor(r, g, b, a); glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ glStencilMask(0xFF); @@ -1763,7 +1773,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); glDisable(GL_SCISSOR_TEST); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -@@ -2861,9 +3860,11 @@ +@@ -2861,9 +3861,11 @@ static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; double t0 = cw_ms(); s_cw_wide_clears++; @@ -1775,7 +1785,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f gl_perf_mirror_begin(); int H = VRAM_H * s_scale; int W = g_wide_w * s_scale; -@@ -3236,10 +4237,153 @@ +@@ -3236,10 +4238,153 @@ } /* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ @@ -1930,7 +1940,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f s_interp_valid = 0; s_interp_w = s_interp_h = 0; s_interp_start = s_interp_last_capture = 0; -@@ -3257,9 +4401,10 @@ +@@ -3257,9 +4402,10 @@ int blend_mode) { double effective_hz = target_hz < 0.0 ? -1.0 @@ -1943,7 +1953,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f const char *diag = getenv("PSX_GL_INTERP_DIAG"); s_interp_diag = diag && diag[0] && diag[0] != '0'; if (active && !s_interp_ctx && s_ctx) { -@@ -3273,6 +4418,7 @@ +@@ -3273,6 +4419,7 @@ * FMV temporarily suspends interpolation and the main context presents. */ SDL_GL_SetSwapInterval(0); if (s_interp_ctx && s_interp_mutex) { @@ -1951,7 +1961,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f SDL_AtomicSet(&s_interp_thread_run, 1); s_interp_thread = SDL_CreateThread(interp_thread_main, "psx-gl-interp", NULL); -@@ -3284,31 +4430,52 @@ +@@ -3284,31 +4431,52 @@ active = 0; } } @@ -2013,7 +2023,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f } void gl_renderer_interpolation_diag(int *enabled, int *suspended, -@@ -3325,12 +4492,69 @@ +@@ -3325,12 +4493,69 @@ if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); } @@ -2083,7 +2093,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f /* The presentation context may still have a draw queued which samples one * of the shared history textures. Order this context's next allocation or * copy after that draw before recycling a texture. glWaitSync keeps the -@@ -3343,6 +4567,7 @@ +@@ -3343,6 +4568,7 @@ int pw = w * s_scale, ph = h * s_scale; if (pw != s_interp_w || ph != s_interp_h || source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { @@ -2091,7 +2101,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f s_interp_valid = 0; s_interp_w = pw; s_interp_h = ph; s_interp_prev = s_interp_cur = 0; -@@ -3365,6 +4590,22 @@ +@@ -3365,6 +4591,22 @@ glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, x * s_scale, y * s_scale, pw, ph); @@ -2114,7 +2124,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); glFlush(); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); -@@ -3388,21 +4629,41 @@ +@@ -3388,21 +4630,41 @@ s_interp_cur = dst; s_interp_valid = 2; } @@ -2157,7 +2167,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); p_glActiveTexture(PSXGL_TEXTURE0); -@@ -3418,12 +4679,135 @@ +@@ -3418,12 +4680,135 @@ p_glUniform1i(s_interp_uCurr, 1); p_glUniform1f(s_interp_uAlpha, alpha); p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); @@ -2294,7 +2304,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f } static int interp_present(void) { -@@ -3438,7 +4822,7 @@ +@@ -3438,7 +4823,7 @@ if (a > 1.0) a = 1.0; if (a < 0.0) a = 0.0; @@ -2303,7 +2313,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f int lx, ly, lw, lh; if (s_interp_force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); -@@ -3450,22 +4834,44 @@ +@@ -3450,22 +4835,44 @@ glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); } @@ -2350,7 +2360,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f uint64_t freq = SDL_GetPerformanceFrequency(); uint64_t deadline = SDL_GetPerformanceCounter(); uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; -@@ -3477,7 +4883,7 @@ +@@ -3477,7 +4884,7 @@ int uncapped = hz < 0.0; uint64_t now = SDL_GetPerformanceCounter(); if (!uncapped) { @@ -2359,7 +2369,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f uint64_t period = (uint64_t)((double)freq / hz); if (!period) period = 1; deadline += period; -@@ -3492,20 +4898,80 @@ +@@ -3492,20 +4899,80 @@ } while (SDL_GetPerformanceCounter() < deadline) {} now = SDL_GetPerformanceCounter(); @@ -2440,7 +2450,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f fflush(stdout); diag_start = now; diag_captures = s_interp_captures; -@@ -3518,6 +4984,33 @@ +@@ -3518,6 +4985,33 @@ SDL_GL_MakeCurrent(s_win, NULL); return 0; } @@ -2474,7 +2484,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f static void present_target_quad(GLuint tex, int tex_w, int tex_h, int x, int y, int w, int h, int linear, -@@ -3553,13 +5046,14 @@ +@@ -3553,13 +5047,14 @@ s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && @@ -2491,7 +2501,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f int lx, ly, lw, lh; if (force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); -@@ -3588,6 +5082,8 @@ +@@ -3588,6 +5083,8 @@ present_target_quad(s_hr_tex, VRAM_W, VRAM_H, disp_x, disp_y, w, h, linear, lx, ly, lw, lh); pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); @@ -2500,7 +2510,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -3607,7 +5103,9 @@ +@@ -3607,7 +5104,9 @@ * x-translated by the reveal offset. No-op when s_wide_fast is off (then the * mirror drew the full surface, as before). Shared by both present paths. */ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { @@ -2511,7 +2521,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f int native_w = g_wide_w - 2 * g_wide_off; if (native_w <= 0) return; int S = s_scale; -@@ -3638,6 +5136,71 @@ +@@ -3638,6 +5137,71 @@ * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y * bottom-origin → window top). Returns 0 if there's no wide surface for base_x * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ @@ -2583,7 +2593,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; -@@ -3653,13 +5216,14 @@ +@@ -3653,13 +5217,14 @@ s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && @@ -2600,7 +2610,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f int lx, ly, lw, lh; letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ -@@ -3681,9 +5245,13 @@ +@@ -3681,9 +5246,13 @@ s_last_dw = g_wide_w; s_last_dh = disp_h; return 1; } @@ -2616,7 +2626,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..0393785031c50ddbc08f68dffa068b4f latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -3702,6 +5270,21 @@ +@@ -3702,6 +5271,21 @@ .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 2005277..cd07dac 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(disruptor-launcher-model STATIC launcher_flow.cpp launcher_settings.cpp region_disc.cpp + report.cpp startup_log.cpp "${PROJECT_SOURCE_DIR}/src/game/language/language_disc.cpp" "${PROJECT_SOURCE_DIR}/src/game/settings/options.cpp") @@ -31,6 +32,16 @@ target_include_directories(disruptor-launcher-model PUBLIC "${PROJECT_SOURCE_DIR target_link_libraries(disruptor-launcher-model PUBLIC disruptor-disc-import disruptor-launcher-config) +# What a report calls this build: the tag it was cut from, or the commits after it. +execute_process(COMMAND git describe --tags --always --dirty + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" OUTPUT_VARIABLE DISRUPTOR_BUILD_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET RESULT_VARIABLE DISRUPTOR_DESCRIBE_FAILED) +if(DISRUPTOR_DESCRIBE_FAILED OR NOT DISRUPTOR_BUILD_VERSION) + set(DISRUPTOR_BUILD_VERSION "unknown") +endif() +set_property(SOURCE report.cpp APPEND PROPERTY COMPILE_DEFINITIONS + "DISRUPTOR_BUILD_VERSION=\"${DISRUPTOR_BUILD_VERSION}\"") + add_executable(disruptor-launcher WIN32 main.cpp) set_target_properties(disruptor-launcher PROPERTIES OUTPUT_NAME DisruptorLauncher @@ -92,6 +103,10 @@ if(BUILD_TESTING) target_link_libraries(disruptor-launcher-flow-test PRIVATE disruptor-launcher-model) add_test(NAME disruptor_launcher_flow COMMAND disruptor-launcher-flow-test) + add_executable(disruptor-launcher-report-test "${PROJECT_SOURCE_DIR}/tests/unit/launcher_report_test.cpp") + target_link_libraries(disruptor-launcher-report-test PRIVATE disruptor-launcher-model) + add_test(NAME disruptor_launcher_report COMMAND disruptor-launcher-report-test) + # The synthetic discs include the layout code whole, so this test takes region_disc.cpp and not the library. add_executable(disruptor-region-disc-test "${PROJECT_SOURCE_DIR}/tests/unit/region_disc_test.cpp" region_disc.cpp) @@ -99,13 +114,16 @@ if(BUILD_TESTING) target_link_libraries(disruptor-region-disc-test PRIVATE disruptor-disc-import) add_test(NAME disruptor_region_disc COMMAND disruptor-region-disc-test) - set_target_properties(disruptor-launcher-settings-test disruptor-launcher-flow-test disruptor-region-disc-test PROPERTIES + set_target_properties(disruptor-launcher-settings-test disruptor-launcher-flow-test disruptor-launcher-report-test + disruptor-region-disc-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") if(MSVC) - set_property(TARGET disruptor-launcher-settings-test disruptor-launcher-flow-test disruptor-region-disc-test + set_property(TARGET disruptor-launcher-settings-test disruptor-launcher-flow-test disruptor-launcher-report-test + disruptor-region-disc-test PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") target_compile_options(disruptor-launcher-settings-test PRIVATE /utf-8) target_compile_options(disruptor-launcher-flow-test PRIVATE /utf-8) + target_compile_options(disruptor-launcher-report-test PRIVATE /utf-8) target_compile_options(disruptor-region-disc-test PRIVATE /utf-8) endif() endif() diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp index 0ab83b7..6dcc433 100644 --- a/src/launcher/main.cpp +++ b/src/launcher/main.cpp @@ -2,6 +2,7 @@ #include "launcher_flow.h" #include "launcher_settings.h" #include "region_disc.h" +#include "report.h" #include "startup_log.h" #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -30,7 +32,7 @@ namespace { constexpr UINT progress_message = WM_APP + 1; constexpr UINT finished_message = WM_APP + 2; constexpr int browse_id = 100, play_id = 101, cancel_id = 102, help_id = 103; -constexpr int language_id = 104, add_disc_id = 105, remove_disc_id = 106, reset_id = 107, saves_id = 108; +constexpr int language_id = 104, add_disc_id = 105, remove_disc_id = 106, reset_id = 107, saves_id = 108, report_id = 109; constexpr int preset_base = 200, option_base = 1000; constexpr int client_width = 680, client_height = 664; constexpr size_t log_lines = 8; @@ -69,6 +71,8 @@ HANDLE start_game(const fs::path& root, const VerifiedDisc& disc) { const auto executable = root / "DisruptorRecompiled.exe"; std::wstring command = game_command(root, disc); SECURITY_ATTRIBUTES security{sizeof(security), nullptr, TRUE}; + std::error_code kept; // a report takes the run before as well: a crash is often seen after a second start + fs::rename(root / "startup.log", root / "startup.previous.log", kept); HANDLE log = CreateFileW((root / "startup.log").c_str(), GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); HANDLE input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, @@ -78,6 +82,11 @@ HANDLE start_game(const fs::path& root, const VerifiedDisc& disc) { if (input != INVALID_HANDLE_VALUE) CloseHandle(input); throw std::runtime_error("Cannot create startup.log. Put the extracted build in a writable Games folder."); } + std::string heading; + for (const auto line : std::views::split(system_note(), std::string_view("\r\n"))) + if (!line.empty()) heading += "launcher: " + std::string(line.begin(), line.end()) + "\r\n"; + DWORD written = 0; + WriteFile(log, heading.data(), static_cast(heading.size()), &written, nullptr); STARTUPINFOW startup{}; startup.cb = sizeof(startup); startup.dwFlags = STARTF_USESTDHANDLES; @@ -479,10 +488,11 @@ void create_controls(App& app) { app.status = app.add(window, L"EDIT", L"", ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL, WS_EX_CLIENTEDGE, 30, 520, 620, 66); app.progress = app.add(window, PROGRESS_CLASSW, L"", 0, 0, 30, 592, 620, 12); - app.play = app.add(window, L"BUTTON", L"Play game", BS_DEFPUSHBUTTON | WS_TABSTOP, 0, 30, 612, 150, 38, play_id); - app.cancel = app.add(window, L"BUTTON", L"Cancel", BS_PUSHBUTTON | WS_TABSTOP, 0, 190, 612, 100, 38, cancel_id); - app.add(window, L"BUTTON", L"Saves folder", BS_PUSHBUTTON | WS_TABSTOP, 0, 394, 612, 126, 38, saves_id); - app.add(window, L"BUTTON", L"Help", BS_PUSHBUTTON | WS_TABSTOP, 0, 530, 612, 120, 38, help_id); + app.play = app.add(window, L"BUTTON", L"Play game", BS_DEFPUSHBUTTON | WS_TABSTOP, 0, 30, 612, 130, 38, play_id); + app.cancel = app.add(window, L"BUTTON", L"Cancel", BS_PUSHBUTTON | WS_TABSTOP, 0, 168, 612, 84, 38, cancel_id); + app.add(window, L"BUTTON", L"Report a problem", BS_PUSHBUTTON | WS_TABSTOP, 0, 260, 612, 134, 38, report_id); + app.add(window, L"BUTTON", L"Saves folder", BS_PUSHBUTTON | WS_TABSTOP, 0, 402, 612, 118, 38, saves_id); + app.add(window, L"BUTTON", L"Help", BS_PUSHBUTTON | WS_TABSTOP, 0, 528, 612, 122, 38, help_id); } void command(App& app, int id, int code) { @@ -509,6 +519,18 @@ void command(App& app, int id, int code) { app.say(L"Open GETTING_STARTED.md in the build folder for setup and troubleshooting help."); } else if (id == saves_id) { ShellExecuteW(window, L"open", app.settings.saves_folder().c_str(), nullptr, nullptr, SW_SHOWNORMAL); + } else if (id == report_id) { + try { + const Report report = make_report(app.root, system_note(), report_stamp()); + const std::wstring shown = L"/select,\"" + report.path.wstring() + L"\""; + ShellExecuteW(window, L"open", L"explorer.exe", shown.c_str(), nullptr, SW_SHOWNORMAL); + app.say((report.archived ? L"Report saved as " : L"Report saved in the folder ") + report.path.wstring() + + L".\nAttach it to a bug report at github.com/micmea668/DisruptorRecomp/issues with the level and the steps. " + L"It holds the game's logs, your settings and a note of the system, with user names cut out of " + L"paths. Your disc image, BIOS and saves are never included."); + } catch (const std::exception& error) { + app.say(wide(error.what())); + } } else if (!app.editable()) { return; } else if (id == language_id && code == CBN_SELCHANGE) { diff --git a/src/launcher/report.cpp b/src/launcher/report.cpp new file mode 100644 index 0000000..06b1775 --- /dev/null +++ b/src/launcher/report.cpp @@ -0,0 +1,160 @@ +#include "report.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef DISRUPTOR_BUILD_VERSION +#define DISRUPTOR_BUILD_VERSION "unknown" +#endif + +namespace fs = std::filesystem; +namespace disruptor::launcher { +namespace { + +constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml"}; +constexpr std::streamoff most_bytes = 4 * 1024 * 1024; // of a log's end: GitHub takes attachments of 25 MB + +bool separates(char letter) { return letter == '\\' || letter == '/'; } + +std::string end_of(const fs::path& file) { + std::ifstream input(file, std::ios::binary | std::ios::ate); + if (!input) return {}; + const std::streamoff size = input.tellg(); + const std::streamoff start = std::max(0, size - most_bytes); + std::string text(static_cast(size - start), '\0'); + input.seekg(start); + input.read(text.data(), static_cast(text.size())); + text.resize(static_cast(std::max(input.gcount(), 0))); + if (start > 0) text.erase(0, std::min(text.find('\n'), text.size() - 1) + 1); // a line cut in two could begin inside a user's name + return text; +} + +void write(const fs::path& file, std::string_view text) { + std::ofstream output(file, std::ios::binary); + output.write(text.data(), static_cast(text.size())); + if (!output) throw std::runtime_error("Cannot write the report. Put the extracted build in a writable Games folder."); +} + +bool run_tar(const fs::path& folder, const fs::path& archive) { + std::wstring system(MAX_PATH, L'\0'); + system.resize(GetSystemDirectoryW(system.data(), MAX_PATH)); + const fs::path tar = fs::path(system) / "tar.exe"; + std::error_code missing; + if (!fs::exists(tar, missing)) return false; + std::wstring command = L"\"" + tar.wstring() + L"\" -a -c -f \"" + archive.filename().wstring() + L"\" \"" + + folder.filename().wstring() + L"\""; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!CreateProcessW(tar.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, + folder.parent_path().c_str(), &startup, &process)) + return false; + DWORD code = 1; + const bool ended = WaitForSingleObject(process.hProcess, 60000) == WAIT_OBJECT_0; + if (ended) GetExitCodeProcess(process.hProcess, &code); + else if (TerminateProcess(process.hProcess, 1)) WaitForSingleObject(process.hProcess, 5000); // its half-made ZIP is open until it is gone + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + std::error_code unreadable; + return ended && code == 0 && fs::file_size(archive, unreadable) > 0 && !unreadable; +} + +std::string narrow(std::wstring_view text) { + if (text.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0, nullptr, nullptr); + std::string out(static_cast(std::max(size, 0)), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(text.size()), out.data(), size, nullptr, nullptr); + return out; +} + +std::string windows_value(const wchar_t* name) { + std::array value{}; + DWORD bytes = static_cast(sizeof(wchar_t) * (value.size() - 1)); + if (RegGetValueW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", name, RRF_RT_REG_SZ, nullptr, + value.data(), &bytes) != ERROR_SUCCESS) + return "?"; + return narrow(value.data()); +} + +} // namespace + +std::string without_user_names(std::string_view text) { + constexpr std::string_view folder = "users"; + std::string out; + out.reserve(text.size()); + size_t at = 0; + while (at < text.size()) { + const bool begins = at > 0 && separates(text[at - 1]) && text.size() - at > folder.size() && + std::equal(folder.begin(), folder.end(), text.begin() + static_cast(at), + [](char wanted, char found) { return std::tolower(static_cast(found)) == wanted; }) && + separates(text[at + folder.size()]); + if (!begins) { + out += text[at++]; + continue; + } + size_t name = at + folder.size(); + while (name < text.size() && separates(text[name])) ++name; + size_t end = name; + while (end < text.size() && !separates(text[end]) && text[end] != '"' && text[end] != '\r' && text[end] != '\n') ++end; + out.append(text, at, name - at); + if (end > name) out += ""; + at = end; + } + return out; +} + +Report make_report(const fs::path& root, std::string_view system, std::string_view stamp, bool archive) { + const fs::path folder = root / "reports" / ("disruptor-report-" + std::string(stamp)); + std::error_code failed; + fs::remove_all(folder, failed); // one of the same second: what tar packs is what is written here and nothing older + fs::create_directories(folder, failed); + write(folder / "system.txt", without_user_names(system)); + for (const char* name : gathered) { + std::error_code missing; + if (fs::is_regular_file(root / name, missing)) write(folder / name, without_user_names(end_of(root / name))); + } + const fs::path zip = fs::path(folder).concat(".zip"); + if (!archive || !run_tar(folder, zip)) { + fs::remove(zip, failed); + return {folder, false}; + } + fs::remove_all(folder, failed); + return {zip, true}; +} + +std::string system_note() { + std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\r\n"; + note += "Windows: " + windows_value(L"ProductName") + ", version " + windows_value(L"DisplayVersion") + ", build " + + windows_value(L"CurrentBuildNumber") + "\r\n"; + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + if (EnumDisplaySettingsW(nullptr, ENUM_CURRENT_SETTINGS, &mode)) + note += "Display: " + std::to_string(mode.dmPelsWidth) + " by " + std::to_string(mode.dmPelsHeight) + " at " + + std::to_string(mode.dmDisplayFrequency) + " Hz\r\n"; + std::set adapters; + DISPLAY_DEVICEW device{}; + device.cb = sizeof(device); + for (DWORD index = 0; EnumDisplayDevicesW(nullptr, index, &device, 0); ++index) + if (device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) adapters.insert(narrow(device.DeviceString)); + for (const auto& adapter : adapters) note += "Graphics: " + adapter + "\r\n"; + return note; +} + +std::string report_stamp() { + const std::time_t now = std::time(nullptr); + std::tm local{}; + localtime_s(&local, &now); + std::array text{}; + std::strftime(text.data(), text.size(), "%Y%m%d-%H%M%S", &local); + return text.data(); +} + +} // namespace disruptor::launcher diff --git a/src/launcher/report.h b/src/launcher/report.h new file mode 100644 index 0000000..afae15e --- /dev/null +++ b/src/launcher/report.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +namespace disruptor::launcher { + +/// `text` with the name of every Windows user folder cut out of its paths: `C:\Users\Name\x` becomes `C:\Users\\x`. +std::string without_user_names(std::string_view text); + +struct Report { + std::filesystem::path path; // the archive, or the folder where no archive could be made + bool archived = false; +}; + +/// Gathers what a bug report needs from the build folder `root` into `reports\disruptor-report-`. +/// +/// Taken are the game's log of the last two runs, the mouse log and the settings, each with user names cut out, and +/// `system` as `system.txt`. The disc image, the BIOS and saves are never taken. With `archive` the folder becomes a +/// ZIP of the same name when Windows' own tar can make one. +/// +/// Throws std::runtime_error when the folder cannot be written. +Report make_report(const std::filesystem::path& root, std::string_view system, std::string_view stamp, bool archive = true); + +/// The build's version, the Windows version, the display and the graphics adapters, a line each. +std::string system_note(); + +/// The local time as `YYYYMMDD-HHMMSS`, for a report's name. +std::string report_stamp(); + +} // namespace disruptor::launcher diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index fbdb419..e753db1 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -208,4 +208,49 @@ def plain_values(text: str) -> dict[str, str]: "the launcher's tests must remain registered", ) +report = read("src/launcher/report.cpp") +playing = read("docs/PLAYING.md") +require( + 'constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml"};\n' + in report + and ' if (fs::is_regular_file(root / name, missing)) write(folder / name, without_user_names(end_of(root / name)));\n' in report + and ' write(folder / "system.txt", without_user_names(system));\n' in report + and " if (!archive || !run_tar(folder, zip)) {\n fs::remove(zip, failed);\n return {folder, false};\n }\n" + " fs::remove_all(folder, failed);\n return {zip, true};\n" in report + and ' std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\\r\\n";\n' in report, + "a report takes the game's log of two runs, the mouse log and the settings, each with user names cut out, and a note " + "of the system: one ZIP where Windows' tar makes it, with no folder and no half-made ZIP left beside it", +) +require( + ' fs::rename(root / "startup.log", root / "startup.previous.log", kept);\n' + ' HANDLE log = CreateFileW((root / "startup.log").c_str(), GENERIC_WRITE, FILE_SHARE_READ,\n' in window + and ' if (!line.empty()) heading += "launcher: " + std::string(line.begin(), line.end()) + "\\r\\n";\n' + " DWORD written = 0;\n" + " WriteFile(log, heading.data(), static_cast(heading.size()), &written, nullptr);\n" in window + and ' app.add(window, L"BUTTON", L"Report a problem", BS_PUSHBUTTON | WS_TABSTOP, 0, 260, 612, 134, 38, report_id);\n' in window + and " } else if (id == report_id) {\n try {\n" + " const Report report = make_report(app.root, system_note(), report_stamp());\n" in window + and window.count("report_id") == 3 + and window.index("} else if (id == report_id) {") < window.index("} else if (!app.editable()) {"), + "the launcher keeps the log of the run before, heads a new log with the build and the system, and has a button that " + "makes a report at any time, an import or a running game included", +) +require( + " const char *card = (const char *)glGetString(GL_RENDERER);\n" + ' fprintf(stdout, "psxrecomp: OpenGL context created (%s, %s)\\n", ver ? ver : "?", card ? card : "?");\n' + in read("psxrecomp/runtime/src/gpu_gl_renderer.c"), + "the game's log names the graphics card beside its driver", +) +require( + " report.cpp\n" in launcher_cmake + and "execute_process(COMMAND git describe --tags --always --dirty\n" in launcher_cmake + and 'set_property(SOURCE report.cpp APPEND PROPERTY COMPILE_DEFINITIONS\n "DISRUPTOR_BUILD_VERSION=\\"${DISRUPTOR_BUILD_VERSION}\\"")\n' + in launcher_cmake + and " add_test(NAME disruptor_launcher_report COMMAND disruptor-launcher-report-test)\n" in launcher_cmake + and "Press **Report a problem** in the launcher." in playing + and "your disc image, BIOS and saves are never\nincluded" in playing, + "the report is built into the launcher with the build's version, its test is registered, and the guide tells a player " + "of the button and of what it leaves out", +) + print("Disruptor launcher source contract: PASS") diff --git a/tests/unit/launcher_report_test.cpp b/tests/unit/launcher_report_test.cpp new file mode 100644 index 0000000..7e45797 --- /dev/null +++ b/tests/unit/launcher_report_test.cpp @@ -0,0 +1,133 @@ +#include "launcher/report.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace disruptor::launcher; + +namespace { + +int g_failures = 0; + +void expect(bool condition, const char* message) { + if (condition) return; + std::cerr << "FAIL: " << message << "\n"; + ++g_failures; +} + +void put(const fs::path& file, const std::string& text) { + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << text; +} + +std::string read(const fs::path& file) { + std::ostringstream text; + text << std::ifstream(file, std::ios::binary).rdbuf(); + return text.str(); +} + +void test_user_names_are_cut() { + expect(without_user_names(R"(disc C:\Users\Alice\Games\d.cue loaded)") == R"(disc C:\Users\\Games\d.cue loaded)", + "a user folder's name is cut out of a path"); + expect(without_user_names(R"(language_disc = "D:\\Users\\Bob Smith\\d.cue")") == R"(language_disc = "D:\\Users\\\\d.cue")", + "and out of a path as a settings file writes it, with a space in the name"); + expect(without_user_names("a /users/carol/x and C:/USERS/Dave") == "a /users//x and C:/USERS/", + "whatever the slashes and the case, to the end of the text"); + expect(without_user_names(R"(one C:\Users\Eve\a two C:\Users\Eve\b)") == R"(one C:\Users\\a two C:\Users\\b)", + "every time it occurs"); + expect(without_user_names(R"(path = "C:\Users\Frank")" "\r\nnext") == R"(path = "C:\Users\")" "\r\nnext", + "a name ends at a quote and at a line's end"); + expect(without_user_names(R"(C:\Games\Users.txt and powerusers\x and C:\Users\)") == R"(C:\Games\Users.txt and powerusers\x and C:\Users\)", + "what is no user folder is left alone, and so is a folder with no name after it"); + expect(without_user_names(R"(C:\Users\Gina)" "\r\nnext") == R"(C:\Users\)" "\r\nnext", + "a name with no quote after it ends at the line's end too"); + expect(without_user_names(R"(C:\Users\O'Connor\Games)") == R"(C:\Users\\Games)", + "an apostrophe is part of a name"); + expect(without_user_names("") .empty() && without_user_names("users") == "users", "short texts pass through"); +} + +void test_a_report_takes_logs_and_settings_only() { + const fs::path root = fs::temp_directory_path() / "disruptor-report-test"; + fs::remove_all(root); + put(root / "startup.log", R"(psxrecomp: loaded C:\Users\Alice\Games\game.toml)" "\n"); + put(root / "startup.previous.log", "the run before\n"); + put(root / "disruptor-mouse-aim.log", "event=ready\n"); + put(root / "settings.toml", R"(language_disc = "C:\\Users\\Alice\\d.cue")" "\n"); + put(root / "bios" / "openbios.bin", "bios"); + put(root / "saves" / "card.mcd", "save"); + put(root / "disc" / "Disruptor.bin", "disc"); + put(root / "DisruptorRecompiled.exe", "exe"); + + const Report plain = make_report(root, R"(system of C:\Users\Alice)", "20261013-010203", false); + expect(!plain.archived && plain.path == root / "reports" / "disruptor-report-20261013-010203" && fs::is_directory(plain.path), + "without an archive the report is a folder named by its stamp under reports"); + expect(read(plain.path / "startup.log") == R"(psxrecomp: loaded C:\Users\\Games\game.toml)" "\n", + "the log is taken with the user's name cut out"); + expect(read(plain.path / "settings.toml") == R"(language_disc = "C:\\Users\\\\d.cue")" "\n", "and so are the settings"); + expect(read(plain.path / "system.txt") == R"(system of C:\Users\)", "and the system note"); + expect(read(plain.path / "startup.previous.log") == "the run before\n" && read(plain.path / "disruptor-mouse-aim.log") == "event=ready\n", + "the run before and the mouse log are taken"); + size_t files = 0; + for (const auto& entry : fs::recursive_directory_iterator(plain.path)) files += entry.is_regular_file(); + expect(files == 5, "nothing else is taken: no BIOS, no save, no disc image, no executable"); + + fs::remove(root / "startup.previous.log"); + fs::remove(root / "disruptor-mouse-aim.log"); + const Report sparse = make_report(root, "system", "sparse", false); + expect(fs::exists(sparse.path / "startup.log") && !fs::exists(sparse.path / "startup.previous.log") && !fs::exists(sparse.path / "disruptor-mouse-aim.log"), + "a file the build folder does not have is not made up"); + + const Report packed = make_report(root, "system", "packed"); + const fs::path folder = root / "reports" / "disruptor-report-packed"; + if (packed.archived) + expect(packed.path == fs::path(folder).concat(".zip") && fs::file_size(packed.path) > 0 && !fs::exists(folder), + "with Windows' tar the report is one ZIP and its folder is gone"); + else + expect(packed.path == folder && fs::exists(folder / "startup.log") && !fs::exists(fs::path(folder).concat(".zip")), + "without it the folder stays and no half-made ZIP is left"); + + std::string long_log = std::string(50, 'x') + R"( C:\Users\Alice\secret)" "\n"; + while (long_log.size() < 4 * 1024 * 1024 + 62) long_log += "filler\n"; + long_log.resize(4 * 1024 * 1024 + 62); + put(root / "startup.log", long_log); + put(root / "reports" / "disruptor-report-long" / "left-over.txt", "from an earlier report of the same second"); + const Report cut = make_report(root, "system", "long", false); + const std::string kept = read(cut.path / "startup.log"); + expect(kept.size() < 4 * 1024 * 1024 && kept.find("secret") == std::string::npos && kept.find("filler\n") < 8, + "of a long log the end is taken, from a whole line on: a line cut inside a user's name is left out"); + expect(!fs::exists(cut.path / "left-over.txt"), "and what an older report left in the folder does not ride along"); + + bool refused = false; + put(root / "blocked", "a file where the reports folder would be"); + try { + (void)make_report(root / "blocked", "system", "refused", false); + } catch (const std::runtime_error&) { + refused = true; + } + expect(refused, "a folder that cannot be made is an error, not a silent nothing"); + fs::remove_all(root); +} + +void test_the_notes_have_their_shape() { + const std::string stamp = report_stamp(); + expect(stamp.size() == 15 && stamp[8] == '-' && stamp.find_first_not_of("0123456789-") == std::string::npos, + "a stamp is a date and a time of digits"); + const std::string note = system_note(); + expect(note.rfind("Disruptor Recompiled ", 0) == 0 && note.find("Windows: ") != std::string::npos, + "the system note begins with the build's version and names the Windows version"); +} + +} // namespace + +int main() { + test_user_names_are_cut(); + test_a_report_takes_logs_and_settings_only(); + test_the_notes_have_their_shape(); + if (g_failures) return 1; + std::cout << "launcher report: PASS\n"; + return 0; +} From 9870a04473303c142e15d75717312347aeb8561c Mon Sep 17 00:00:00 2001 From: Solganis Date: Tue, 13 Oct 2026 05:19:21 +0300 Subject: [PATCH 2/4] feat: add a detailed log for bug reports, switched on a Troubleshooting page --- CMakeLists.txt | 12 + README.md | 3 +- docs/PLAYING.md | 11 +- .../runtime/src/gpu_gl_renderer.c.patch | 168 ++++++++--- framework/patches/runtime/src/main.cpp.patch | 197 ++++++------ src/game/host.cpp | 2 + src/game/session/diagnostics.cpp | 246 +++++++++++++++ src/game/session/diagnostics.h | 71 +++++ src/game/settings/game_user_settings.h | 1 + src/game/settings/options.cpp | 17 ++ src/game/settings/options.h | 5 + src/game/settings/saved.cpp | 8 +- src/game/ui/menu/menu.h | 2 + src/game/ui/menu/preferences.cpp | 8 + src/game/ui/menu/tab_debug.cpp | 13 +- src/launcher/launcher_settings.cpp | 7 +- src/launcher/launcher_settings.h | 3 +- src/launcher/main.cpp | 9 +- src/launcher/report.cpp | 114 ++++++- src/launcher/report.h | 15 +- src/launcher/startup_log.cpp | 14 + src/launcher/startup_log.h | 3 + tests/contracts/test_dev_menu_contract.py | 6 +- tests/contracts/test_diagnostics_contract.py | 160 ++++++++++ tests/contracts/test_launcher_contract.py | 8 +- tests/support/stubs/psx_sdl.h | 5 + tests/unit/disruptor_diagnostics_test.cpp | 282 ++++++++++++++++++ tests/unit/launcher_report_test.cpp | 97 +++++- tests/unit/launcher_settings_test.cpp | 41 +++ tests/unit/user_settings_persistence_test.cpp | 3 + 30 files changed, 1365 insertions(+), 166 deletions(-) create mode 100644 src/game/session/diagnostics.cpp create mode 100644 src/game/session/diagnostics.h create mode 100644 tests/contracts/test_diagnostics_contract.py create mode 100644 tests/unit/disruptor_diagnostics_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bfed12..ddf258f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ set(DISRUPTOR_EXTRA_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/capsule.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/cheats.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/input/control_probe.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/diagnostics.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/render/far_rendering.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/frame/frame_rate.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/game/host.cpp" @@ -220,6 +221,7 @@ if(BUILD_TESTING) disruptor_contract(smooth_turn) disruptor_contract(ambient_occlusion) disruptor_contract(debug_tab) + disruptor_contract(diagnostics) disruptor_contract(instruction_hooks) disruptor_contract(state_key) disruptor_contract(test_registration) @@ -281,6 +283,16 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) set_tests_properties(disruptor_high_precision_camera_registration PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=1") + disruptor_unit_test(disruptor_diagnostics tests/unit/disruptor_diagnostics_test.cpp + SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/diagnostics.cpp" + INCLUDES tests/support/stubs + DEFINITIONS PSX_SDL3=1) + set_tests_properties(disruptor_diagnostics PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_DETAILED_LOGGING=") + add_test(NAME disruptor_diagnostics_forced COMMAND disruptor-diagnostics-test --forced) + set_tests_properties(disruptor_diagnostics_forced PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_DETAILED_LOGGING=1") + add_test(NAME disruptor_diagnostics_refused COMMAND disruptor-diagnostics-test --refused) + set_tests_properties(disruptor_diagnostics_refused PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_DETAILED_LOGGING=0") + disruptor_unit_test(disruptor_cheats tests/unit/disruptor_cheats_test.cpp RUNTIME_HEADERS) disruptor_unit_test(disruptor_vertical_camera tests/unit/disruptor_vertical_camera_test.cpp RUNTIME_HEADERS) disruptor_unit_test(disruptor_far_rendering tests/unit/disruptor_far_rendering_test.cpp RUNTIME_HEADERS) diff --git a/README.md b/README.md index 6dbff61..cf8d2f9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ disc revision; the redistributable OpenBIOS backend is used for booting. - Experimental vertical mouse look and weapon aim. - In-game settings for controls, display, fullscreen, VSync, volume, and mute. - Diagnostic views and a screen recorder for reports of a fault in the picture. -- A launcher button that gathers logs and settings for a bug report. +- A launcher button that gathers logs and settings for a bug report, and a + detailed log to switch on for one. - Memory-card saves and twelve save-state slots. - Windows launcher with disc-image browsing, automatic copy/CUE setup, full-disc SHA-256 verification, the game's settings with a hint for each, diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fb18bae..dbe21b2 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -149,7 +149,8 @@ as cheated, including subsequent game saves. ## Reporting a problem Press **Report a problem** in the launcher. It gathers the game's log of the -last two runs, the mouse log, your settings and a note of the system into one +last two runs, the mouse log, your settings, the notes the game leaves of how +its last run ended and of a freeze, and a note of the system into one ZIP under `reports/` beside the executable and shows it in Explorer. Where Windows cannot make the ZIP, it is a folder of the same name: attach its files. The name of your Windows user folder is cut out of paths, and @@ -159,6 +160,14 @@ included. Attach the ZIP to a level and the steps that lead to the problem. For a fault in the picture add a replay capsule or a screen recording, described below. +For a problem you can repeat, first switch on **Detailed logging** on the +launcher's **Troubleshooting** page, play until it shows, close the game and +then make the report. The log then also says every ten seconds how the +frames ran, each setting changed in the game's menu, controllers connected +and removed, and what the graphics driver complains of. The switch is in the +game's menu too, under **Settings → Debug**, where the driver's part starts +with the next launch. + ## Reports and replay capsules **Settings → Debug** offers diagnostic views and **Record the screen** for diff --git a/framework/patches/runtime/src/gpu_gl_renderer.c.patch b/framework/patches/runtime/src/gpu_gl_renderer.c.patch index e222c96..b60d439 100644 --- a/framework/patches/runtime/src/gpu_gl_renderer.c.patch +++ b/framework/patches/runtime/src/gpu_gl_renderer.c.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c -index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54f6786455 100644 +index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..c40006a9fc560ec447076073c5069909f11c44bf 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -67,6 +67,9 @@ @@ -1489,7 +1489,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 } p_glGenVertexArrays(1, &s_blit_vao); -@@ -2433,6 +3318,12 @@ +@@ -2433,9 +3318,80 @@ gl_perf_init(); /* frame_perf GPU/CPU phase timing (no-op if queries absent) */ fprintf(stdout, "psxrecomp: GL GPU pipeline ready (internal scale %dx, " "mask-bit stencil, texture window, GPU copy/upload)\n", s_scale); @@ -1502,17 +1502,98 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 return 1; } -@@ -2454,7 +3345,8 @@ ++/* The driver's own messages, for the game's detailed log: a driver may call from its own thread, so the call only queues. */ ++typedef void (APIENTRY *PFN_glDebugProc)(GLenum, GLenum, GLuint, GLenum, GLsizei, const char *, const void *); ++typedef void (APIENTRY *PFN_glDebugMessageCallback)(PFN_glDebugProc, const void *); ++typedef void (APIENTRY *PFN_glDebugMessageControl)(GLenum, GLenum, GLenum, GLsizei, const GLuint *, GLboolean); ++#define DEBUG_QUEUE 16 ++static struct { char text[200]; unsigned id; int severity, context; } s_debug_queue[DEBUG_QUEUE]; ++static SDL_SpinLock s_debug_lock; ++static int s_debug_asked, s_debug_active, s_debug_count; ++static unsigned s_debug_lost; ++ ++static void APIENTRY debug_message(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, ++ const char *message, const void *presenter) { ++ (void)source; (void)type; (void)length; ++ SDL_AtomicLock(&s_debug_lock); ++ if (s_debug_count < DEBUG_QUEUE) { ++ snprintf(s_debug_queue[s_debug_count].text, sizeof s_debug_queue[0].text, "%s", message ? message : ""); ++ s_debug_queue[s_debug_count].id = id; ++ s_debug_queue[s_debug_count].severity = severity == 0x9146 /* GL_DEBUG_SEVERITY_HIGH */ ? 2 : 1; ++ s_debug_queue[s_debug_count].context = presenter != NULL; ++ ++s_debug_count; ++ } else { ++ ++s_debug_lost; ++ } ++ SDL_AtomicUnlock(&s_debug_lock); ++} ++ ++/* For the context that is current. KHR_debug is core from GL 4.3 and an extension below it. */ ++static void debug_install(int presenter) { ++ static int s_presenter_tag; ++ GLint major = 0, minor = 0; ++ if (!s_debug_asked) return; ++ glGetIntegerv(0x821B /* GL_MAJOR_VERSION */, &major); ++ glGetIntegerv(0x821C /* GL_MINOR_VERSION */, &minor); ++ if (major * 10 + minor < 43 && !SDL_GL_ExtensionSupported("GL_KHR_debug")) return; ++ PFN_glDebugMessageCallback callback = (void *)SDL_GL_GetProcAddress("glDebugMessageCallback"); ++ PFN_glDebugMessageControl control = (void *)SDL_GL_GetProcAddress("glDebugMessageControl"); ++ if (!callback || !control) return; ++ glEnable(0x92E0 /* GL_DEBUG_OUTPUT */); ++ control(GL_DONT_CARE, GL_DONT_CARE, 0x826B /* GL_DEBUG_SEVERITY_NOTIFICATION */, 0, NULL, GL_FALSE); ++ control(GL_DONT_CARE, GL_DONT_CARE, 0x9148 /* GL_DEBUG_SEVERITY_LOW */, 0, NULL, GL_FALSE); ++ callback(debug_message, presenter ? &s_presenter_tag : NULL); ++ s_debug_active = 1; ++} ++ ++void gl_renderer_set_debug_messages(int enabled) { s_debug_asked = enabled != 0; } ++int gl_renderer_debug_messages(void) { return s_debug_asked; } ++int gl_renderer_debug_messages_active(void) { return s_debug_active; } ++/* The oldest queued message, 0 when there is none. `lost` gets how many found the queue full since the last call. */ ++int gl_renderer_debug_message_take(char *text, int size, unsigned *id, int *severity, int *context, unsigned *lost) { ++ int taken = 0; ++ SDL_AtomicLock(&s_debug_lock); ++ *lost = s_debug_lost; ++ s_debug_lost = 0; ++ if (s_debug_count > 0) { ++ snprintf(text, (size_t)size, "%s", s_debug_queue[0].text); ++ *id = s_debug_queue[0].id; ++ *severity = s_debug_queue[0].severity; ++ *context = s_debug_queue[0].context; ++ memmove(&s_debug_queue[0], &s_debug_queue[1], (size_t)(--s_debug_count) * sizeof s_debug_queue[0]); ++ taken = 1; ++ } ++ SDL_AtomicUnlock(&s_debug_lock); ++ return taken; ++} ++ + int gl_renderer_init_context(SDL_Window *win) { + s_win = win; + s_present_w = 0; +@@ -2443,6 +3399,11 @@ + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + s_ctx = SDL_GL_CreateContext(win); ++ if (!s_ctx && s_debug_asked) { /* a driver with no debug context still has a plain one */ ++ s_debug_asked = 0; ++ SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); ++ s_ctx = SDL_GL_CreateContext(win); ++ } + if (!s_ctx) { fprintf(stdout, "psxrecomp: GL context creation failed (%s)\n", SDL_GetError()); return 0; } + if (SDL_GL_MakeCurrent(win, s_ctx) != 0) { fprintf(stdout, "psxrecomp: MakeCurrent failed (%s)\n", SDL_GetError()); SDL_GL_DeleteContext(s_ctx); s_ctx=NULL; return 0; } + /* Swap interval: 1=vsync (tear-free, default), 0=immediate (lowest display +@@ -2454,7 +3415,9 @@ } glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); const char *ver = (const char *)glGetString(GL_VERSION); - fprintf(stdout, "psxrecomp: OpenGL context created (%s)\n", ver ? ver : "?"); + const char *card = (const char *)glGetString(GL_RENDERER); + fprintf(stdout, "psxrecomp: OpenGL context created (%s, %s)\n", ver ? ver : "?", card ? card : "?"); ++ debug_install(0); /* All-or-nothing: any missing entry point / failed shader / bad FBO means * the whole GL renderer is unavailable and the runtime stays on the pure -@@ -2467,11 +3359,60 @@ +@@ -2467,11 +3430,60 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); s_present_prog = build_program(PRESENT_VS, PRESENT_FS); @@ -1573,7 +1654,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 s_interp_uPrev = p_glGetUniformLocation(s_interp_prog, "u_prev"); s_interp_uCurr = p_glGetUniformLocation(s_interp_prog, "u_curr"); s_interp_uAlpha = p_glGetUniformLocation(s_interp_prog, "u_alpha"); -@@ -2484,6 +3425,7 @@ +@@ -2484,6 +3496,7 @@ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); } @@ -1581,7 +1662,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 } else ok = 0; } if (ok) ok = init_gpu_raster(); -@@ -2496,20 +3438,28 @@ +@@ -2496,20 +3509,28 @@ return 1; } @@ -1623,7 +1704,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 if (s_interp_thread) { SDL_AtomicSet(&s_interp_thread_run, 0); SDL_WaitThread(s_interp_thread, NULL); -@@ -2532,12 +3482,25 @@ +@@ -2532,12 +3553,25 @@ } } ensure_cpu(); @@ -1649,7 +1730,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 free(s_conv); s_conv = NULL; s_raster_ok = 0; /* New context regenerates s_present_tex empty; a stale size makes -@@ -2549,16 +3512,36 @@ +@@ -2549,16 +3583,36 @@ rect_clear(&s_d24_skip_fb); } @@ -1687,7 +1768,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 glDisable(GL_SCISSOR_TEST); glViewport(0, 0, ww, wh); glClearColor(0.f,0.f,0.f,1.f); glClear(GL_COLOR_BUFFER_BIT); -@@ -2608,6 +3591,8 @@ +@@ -2608,6 +3662,8 @@ p_glBindVertexArray(s_present_vao); glDrawArrays(GL_TRIANGLES, 0, 3); p_glBindVertexArray(0); p_glUseProgram(0); pres_record(GL_PRES_CPU, 0, 0, src_w, src_h, lx, ly, lw, lh); @@ -1696,7 +1777,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -2618,10 +3603,12 @@ +@@ -2618,10 +3674,12 @@ void gl_renderer_present_blank(void) { if (!s_ctx) return; interp_reset_history(); @@ -1710,7 +1791,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -2749,6 +3736,7 @@ +@@ -2749,6 +3807,7 @@ * ------------------------------------------------------------------------- */ static void wide_free_all(void) { @@ -1718,7 +1799,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 for (int i = 0; i < WIDE_MAX_SURF; i++) { if (s_wide_fbo[i]) { p_glDeleteFramebuffers(1, &s_wide_fbo[i]); s_wide_fbo[i] = 0; } if (s_wide_tex[i]) { glDeleteTextures(1, &s_wide_tex[i]); s_wide_tex[i] = 0; } -@@ -2802,9 +3790,11 @@ +@@ -2802,9 +3861,11 @@ static void glb_wide_configure(int wide_w, int offset) { if (!s_raster_ok) return; double t0 = cw_ms(); s_cw_wide_cfgs++; @@ -1730,7 +1811,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 g_wide_w = wide_w; g_wide_off = offset; s_cw_wide_ms += cw_ms() - t0; -@@ -2814,6 +3804,7 @@ +@@ -2814,6 +3875,7 @@ static void glb_wide_set_target(int base_x) { if (!s_raster_ok) { g_wide_cur = 0; return; } double t0 = cw_ms(); s_cw_wide_sets++; @@ -1738,7 +1819,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 flush_tex_batch(); /* drain into the OLD target before switching */ g_wide_cur = wide_fbo_for(base_x); g_wide_cur_base = base_x; -@@ -2821,7 +3812,7 @@ +@@ -2821,7 +3883,7 @@ } /* Stop mirroring (offscreen draws that don't target a framebuffer). */ @@ -1747,7 +1828,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 /* Mirror a framebuffer clear: fill the full wide width over [y, y+h) of the * surface for base_x, so the revealed margins are clean. Mirrors sw_wide_clear: -@@ -2829,9 +3820,11 @@ +@@ -2829,9 +3891,11 @@ static void glb_wide_clear(int base_x, int y, int h, uint16_t color) { if (!s_raster_ok || s_ws_ablate == 1) return; double t0 = cw_ms(); s_cw_wide_clears++; @@ -1759,7 +1840,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 gl_perf_mirror_begin(); int H = VRAM_H * s_scale; int y0 = y * s_scale, y1 = (y + h) * s_scale; -@@ -2849,6 +3842,13 @@ +@@ -2849,6 +3913,13 @@ glClearColor(r, g, b, a); glClearStencil((color >> 15) & 1); /* stencil mirrors bit15, like the color alpha */ glStencilMask(0xFF); @@ -1773,7 +1854,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); glDisable(GL_SCISSOR_TEST); p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); -@@ -2861,9 +3861,11 @@ +@@ -2861,9 +3932,11 @@ static void glb_wide_clear_margins(int base_x, int y, int h, uint16_t color, int sides) { if (!s_raster_ok || s_ws_ablate == 1 || g_wide_off <= 0) return; double t0 = cw_ms(); s_cw_wide_clears++; @@ -1785,7 +1866,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 gl_perf_mirror_begin(); int H = VRAM_H * s_scale; int W = g_wide_w * s_scale; -@@ -3236,10 +4238,153 @@ +@@ -3236,10 +4309,153 @@ } /* Native-wide mirror ablation (perf attribution): see s_ws_ablate. */ @@ -1940,7 +2021,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 s_interp_valid = 0; s_interp_w = s_interp_h = 0; s_interp_start = s_interp_last_capture = 0; -@@ -3257,9 +4402,10 @@ +@@ -3257,15 +4473,21 @@ int blend_mode) { double effective_hz = target_hz < 0.0 ? -1.0 @@ -1953,7 +2034,18 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 const char *diag = getenv("PSX_GL_INTERP_DIAG"); s_interp_diag = diag && diag[0] && diag[0] != '0'; if (active && !s_interp_ctx && s_ctx) { -@@ -3273,6 +4419,7 @@ + if (!s_interp_mutex) s_interp_mutex = SDL_CreateMutex(); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1); + s_interp_ctx = SDL_GL_CreateContext(s_win); ++ if (!s_interp_ctx && s_debug_asked) { /* as for the main context: a plain one, shared all the same */ ++ SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); ++ s_interp_ctx = SDL_GL_CreateContext(s_win); ++ } ++ if (s_interp_ctx) debug_install(1); + SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0); + SDL_GL_MakeCurrent(s_win, s_ctx); + /* The wall-clock pacer and interpolation scheduler own cadence. Keeping +@@ -3273,6 +4495,7 @@ * FMV temporarily suspends interpolation and the main context presents. */ SDL_GL_SetSwapInterval(0); if (s_interp_ctx && s_interp_mutex) { @@ -1961,7 +2053,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 SDL_AtomicSet(&s_interp_thread_run, 1); s_interp_thread = SDL_CreateThread(interp_thread_main, "psx-gl-interp", NULL); -@@ -3284,31 +4431,52 @@ +@@ -3284,31 +4507,52 @@ active = 0; } } @@ -2023,7 +2115,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 } void gl_renderer_interpolation_diag(int *enabled, int *suspended, -@@ -3325,12 +4493,69 @@ +@@ -3325,12 +4569,69 @@ if (s_interp_mutex) SDL_UnlockMutex(s_interp_mutex); } @@ -2093,7 +2185,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 /* The presentation context may still have a draw queued which samples one * of the shared history textures. Order this context's next allocation or * copy after that draw before recycling a texture. glWaitSync keeps the -@@ -3343,6 +4568,7 @@ +@@ -3343,6 +4644,7 @@ int pw = w * s_scale, ph = h * s_scale; if (pw != s_interp_w || ph != s_interp_h || source_path != s_interp_source_path || force_4_3 != s_interp_force_4_3) { @@ -2101,7 +2193,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 s_interp_valid = 0; s_interp_w = pw; s_interp_h = ph; s_interp_prev = s_interp_cur = 0; -@@ -3365,6 +4591,22 @@ +@@ -3365,6 +4667,22 @@ glBindTexture(GL_TEXTURE_2D, s_interp_tex[dst]); glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, x * s_scale, y * s_scale, pw, ph); @@ -2124,7 +2216,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 s_interp_fence[dst] = p_glFenceSync(PSXGL_SYNC_GPU_COMMANDS_COMPLETE, 0); glFlush(); p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); -@@ -3388,21 +4630,41 @@ +@@ -3388,21 +4706,41 @@ s_interp_cur = dst; s_interp_valid = 2; } @@ -2167,7 +2259,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); p_glActiveTexture(PSXGL_TEXTURE0); -@@ -3418,12 +4680,135 @@ +@@ -3418,12 +4756,135 @@ p_glUniform1i(s_interp_uCurr, 1); p_glUniform1f(s_interp_uAlpha, alpha); p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode); @@ -2304,7 +2396,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 } static int interp_present(void) { -@@ -3438,7 +4823,7 @@ +@@ -3438,7 +4899,7 @@ if (a > 1.0) a = 1.0; if (a < 0.0) a = 0.0; @@ -2313,7 +2405,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 int lx, ly, lw, lh; if (s_interp_force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); -@@ -3450,22 +4835,44 @@ +@@ -3450,22 +4911,44 @@ glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT); } @@ -2360,7 +2452,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 uint64_t freq = SDL_GetPerformanceFrequency(); uint64_t deadline = SDL_GetPerformanceCounter(); uint64_t diag_start = deadline, diag_swaps = 0, diag_captures = 0; -@@ -3477,7 +4884,7 @@ +@@ -3477,7 +4960,7 @@ int uncapped = hz < 0.0; uint64_t now = SDL_GetPerformanceCounter(); if (!uncapped) { @@ -2369,7 +2461,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 uint64_t period = (uint64_t)((double)freq / hz); if (!period) period = 1; deadline += period; -@@ -3492,20 +4899,80 @@ +@@ -3492,20 +4975,80 @@ } while (SDL_GetPerformanceCounter() < deadline) {} now = SDL_GetPerformanceCounter(); @@ -2450,7 +2542,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 fflush(stdout); diag_start = now; diag_captures = s_interp_captures; -@@ -3518,6 +4985,33 @@ +@@ -3518,6 +5061,33 @@ SDL_GL_MakeCurrent(s_win, NULL); return 0; } @@ -2484,7 +2576,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 static void present_target_quad(GLuint tex, int tex_w, int tex_h, int x, int y, int w, int h, int linear, -@@ -3553,13 +5047,14 @@ +@@ -3553,13 +5123,14 @@ s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && @@ -2501,7 +2593,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 int lx, ly, lw, lh; if (force_4_3) letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); -@@ -3588,6 +5083,8 @@ +@@ -3588,6 +5159,8 @@ present_target_quad(s_hr_tex, VRAM_W, VRAM_H, disp_x, disp_y, w, h, linear, lx, ly, lw, lh); pres_record(GL_PRES_VRAM, disp_x, disp_y, w, h, lx, ly, lw, lh); @@ -2510,7 +2602,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -3607,7 +5104,9 @@ +@@ -3607,7 +5180,9 @@ * x-translated by the reveal offset. No-op when s_wide_fast is off (then the * mirror drew the full surface, as before). Shared by both present paths. */ static void wide_blit_center(GLuint wide_fbo, int base_x, int disp_y, int disp_h) { @@ -2521,7 +2613,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 int native_w = g_wide_w - 2 * g_wide_off; if (native_w <= 0) return; int S = s_scale; -@@ -3638,6 +5137,71 @@ +@@ -3638,6 +5213,71 @@ * disp_y+disp_h] region into the letterbox, V-flipped like present_vram (FBO y * bottom-origin → window top). Returns 0 if there's no wide surface for base_x * (caller falls back). disp_x is the displayed buffer base (the wide-surface key). */ @@ -2593,7 +2685,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) { if (!s_ctx || !s_raster_ok || g_wide_w <= 0) return 0; GLuint fbo = 0, tex = 0; -@@ -3653,13 +5217,14 @@ +@@ -3653,13 +5293,14 @@ s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && @@ -2610,7 +2702,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 int lx, ly, lw, lh; letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); wide_blit_center(fbo, disp_x, disp_y, disp_h); /* fast-path: authoritative centre */ -@@ -3681,9 +5246,13 @@ +@@ -3681,9 +5322,13 @@ s_last_dw = g_wide_w; s_last_dh = disp_h; return 1; } @@ -2626,7 +2718,7 @@ index ec8bc5f74bd7dc13d6d7d1867f5864940e720767..2fd8376f511c435871821b3208a68f54 latency_ring_mark(LAT_SWAP_BEGIN); SDL_GL_SwapWindow(s_win); latency_ring_mark(LAT_SWAP_END); -@@ -3702,6 +5271,21 @@ +@@ -3702,6 +5347,21 @@ .set_texture_filter = glb_set_texture_filter, .texture_filter = glb_texture_filter, .set_semi_transparency = glb_set_semi_transparency, .set_mask_bits = glb_set_mask_bits, .set_texture_window = glb_set_texture_window, .set_color_modulation = glb_set_color_modulation, diff --git a/framework/patches/runtime/src/main.cpp.patch b/framework/patches/runtime/src/main.cpp.patch index 8dd2fd4..fd7ea86 100644 --- a/framework/patches/runtime/src/main.cpp.patch +++ b/framework/patches/runtime/src/main.cpp.patch @@ -1,5 +1,5 @@ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp -index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e352b13313 100644 +index 81e048307ec9dd9fc7234a1e6285042ffab16457..2eb1dbdf45c2ef81fe95bb16c68ef5b430608ff1 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1,9 +1,16 @@ @@ -52,7 +52,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #include #include #include -@@ -100,6 +118,11 @@ +@@ -100,6 +118,12 @@ #include #include @@ -60,11 +60,12 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 + * content suspension, so either owner can release independently. */ +extern "C" void gl_renderer_set_host_ui_suspended(int suspended); +extern "C" void gl_renderer_set_hidden_rows(int rows); ++extern "C" int gl_renderer_debug_messages(void); + #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN -@@ -108,7 +131,7 @@ +@@ -108,7 +132,7 @@ #include #include #include @@ -73,7 +74,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #else #include #include -@@ -131,6 +154,20 @@ +@@ -131,6 +155,20 @@ #ifndef DEFAULT_DEBUG_PORT #error DEFAULT_DEBUG_PORT must be defined by the runtime target. #endif @@ -94,7 +95,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 extern "C" uint64_t gte_get_exec_count(void); -@@ -164,11 +201,12 @@ +@@ -164,11 +202,12 @@ * every install must arm it — not just repo checkouts that happen to carry a * loose EXE copy next to game.toml. Source order: * 1. The local EXE file from game.toml (dev checkouts; recompiler input). @@ -109,7 +110,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 const std::string &disc_path) { const uint32_t phys_lo = load_address & 0x1FFFFFFFu; /* 1. Local EXE file (skip the 2048-byte PS-X EXE header). */ -@@ -177,9 +215,12 @@ +@@ -177,9 +216,12 @@ if (ef) { std::streamsize sz = ef.tellg(); if (sz > 2048) { @@ -125,7 +126,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 ef.seekg(2048, std::ios::beg); if (ef.read((char *)img, img_len)) { dirty_ram_register_text_image(phys_lo, img, img_len); -@@ -193,7 +234,10 @@ +@@ -193,7 +235,10 @@ } } } @@ -137,7 +138,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (!disc_path.empty()) { PS1::ISOReader iso; if (iso.Open(disc_path)) { -@@ -239,17 +283,38 @@ +@@ -239,17 +284,38 @@ uint8_t *file = (uint8_t *)std::malloc(ent.size); if (file) { size_t got = iso.ReadFile(boot_name, file, ent.size); @@ -183,7 +184,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 return; } } -@@ -317,6 +382,10 @@ +@@ -317,6 +383,10 @@ * 640*scale x 512*scale. Allocated once the supersampling scale is known * (sized for the native 640x512 when supersampling is off). */ static uint32_t* sdl_pixel_buf = nullptr; @@ -194,7 +195,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 typedef void (*ModFrameHook)(void); -@@ -337,8 +406,118 @@ +@@ -337,8 +407,118 @@ for (size_t i = 0; i < count; i++) hooks[i](); } @@ -313,7 +314,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 static std::atomic g_smooth_60fps{0}; struct Smooth60State { -@@ -352,6 +531,7 @@ +@@ -352,6 +532,7 @@ }; static Smooth60State g_smooth_60_state; @@ -321,7 +322,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Present-path session state — must reset on rematch (function-local statics * survive soft-return and poison FMV/FPS after session_reboot). */ -@@ -379,12 +559,14 @@ +@@ -379,12 +560,14 @@ } static void smooth_60_reset(void) { @@ -336,7 +337,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } static void present_session_reset(void) { -@@ -402,10 +584,18 @@ +@@ -402,10 +585,18 @@ smooth_60_reset(); } @@ -355,7 +356,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 s_disabled_frame_presented = false; s_force_present_after_load = true; smooth_60_reset(); -@@ -419,8 +609,10 @@ +@@ -419,8 +610,10 @@ * tiles + force several swaps so the window actually updates. Safe no-op * when the GL pipeline was never brought up. */ gl_renderer_invalidate_present(); @@ -368,7 +369,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { uint64_t hash = 1469598103934665603ull; const size_t step = std::max(1, count / 4096u); -@@ -504,6 +696,13 @@ +@@ -504,6 +697,13 @@ extern "C" void psx_smooth_60fps_set(int enabled) { g_smooth_60fps.store(enabled ? 1 : 0, std::memory_order_release); } @@ -382,7 +383,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #if defined(PSX_WEB) extern "C" EMSCRIPTEN_KEEPALIVE void psx_web_set_smooth_60fps(int enabled) { psx_smooth_60fps_set(enabled); -@@ -515,6 +714,8 @@ +@@ -515,6 +715,8 @@ static bool g_video_aa = true; /* linear present filtering */ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; @@ -391,7 +392,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ -@@ -599,10 +800,17 @@ +@@ -599,10 +801,17 @@ static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; @@ -409,7 +410,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 static int g_mod_controller_mode_override[2] = { -1, -1 }; static_assert((int)PSX_MOD_CONTROLLER_HYBRID == (int)PSXRecompV4::PAD_MODE_HYBRID); -@@ -630,6 +838,111 @@ +@@ -630,6 +839,111 @@ return 0; /* windowed */ } @@ -521,7 +522,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ extern "C" int cdrom_xa_stream_active(void); extern "C" uint32_t mdec_get_decode_count(void); -@@ -655,12 +968,105 @@ +@@ -655,12 +969,105 @@ * in config_loader.h). */ static int g_video_aspect_num = 4; static int g_video_aspect_den = 3; @@ -627,7 +628,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { -@@ -738,6 +1144,10 @@ +@@ -738,6 +1145,10 @@ extern "C" int psx_mod_set_frame_interpolation( uint32_t frames_per_second) { @@ -638,7 +639,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (frames_per_second != 0 && (frames_per_second < 60 || frames_per_second > 1000)) { std::fprintf(stderr, -@@ -768,10 +1178,15 @@ +@@ -768,10 +1179,15 @@ "interpolation; guest timing remains stock\n"); } return 1; @@ -654,7 +655,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR && blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) { std::fprintf(stderr, -@@ -784,6 +1199,7 @@ +@@ -784,6 +1200,7 @@ blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE ? "motion-adaptive clarity" : "linear crossfade"); return 1; @@ -662,7 +663,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { -@@ -867,6 +1283,8 @@ +@@ -867,6 +1284,8 @@ * 4:3, wider for wide aspects. Height is always 480*scale. Set at window * creation alongside SDL_RenderSetLogicalSize. */ static int g_logical_w = 640; @@ -671,7 +672,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Clamp a requested window width to the primary display's usable area so an * oversized choice (e.g. 1920 on a 1080p panel) still fits on screen. Keeps -@@ -886,6 +1304,103 @@ +@@ -886,6 +1305,103 @@ static int aspect_gcd(int a, int b) { while (b) { int t = a % b; a = b; b = t; } return a > 0 ? a : 1; @@ -775,7 +776,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } /* Follow the host window without feeding its absolute pixel size into guest -@@ -911,23 +1426,7 @@ +@@ -911,23 +1427,7 @@ den /= divisor; } if (num == g_video_aspect_num && den == g_video_aspect_den) return; @@ -800,7 +801,15 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } /* SDL GL attributes are global inputs to the next context creation. Set the -@@ -955,8 +1454,107 @@ +@@ -939,6 +1439,7 @@ + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); ++ SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, gl_renderer_debug_messages() ? SDL_GL_CONTEXT_DEBUG_FLAG : 0); + } + + /* Live native-wide vs squash toggle (ws_nw TCP command) for A/B comparison. +@@ -955,8 +1456,107 @@ } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } @@ -910,7 +919,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Present straight from the FBO (fast, no readback). Set PSX_GL_FORCE_CPU_PRESENT=1 * to force the software readout path instead — a diagnostic/fallback that also * keeps CPU VRAM current every frame (so screenshots reflect the screen). */ -@@ -994,6 +1592,16 @@ +@@ -994,6 +1594,16 @@ static int g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; static SDL_AudioDeviceID sdl_audio_device; static int16_t sdl_audio_buf[2048 * 2]; @@ -927,7 +936,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* DRC bridge. Producer (sdl_audio_pump) runs on the main loop thread under * SDL_LockAudioDevice; consumer (sdl_drc_callback) runs on the SDL audio -@@ -1026,10 +1634,36 @@ +@@ -1026,10 +1636,36 @@ int g_audio_host_rate = 44100; } @@ -964,7 +973,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* T3 tap in bridge mode: the exact device-rate bytes the host consumes. * This callback is the tap's single writer while the bridge is active. */ audio_trace_pcm(AUDIO_TAP_HOST, reinterpret_cast(stream), -@@ -1174,22 +1808,40 @@ +@@ -1174,22 +1810,40 @@ return false; } #ifdef _WIN32 @@ -1021,7 +1030,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #else (void)filter; (void)out; -@@ -1205,6 +1857,46 @@ +@@ -1205,6 +1859,46 @@ title, cli_flag); return false; #endif @@ -1068,7 +1077,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } static std::string uppercase_ascii(std::string s) { -@@ -1590,6 +2282,9 @@ +@@ -1590,6 +2284,9 @@ } static void shutdown_runtime(void) { @@ -1078,7 +1087,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the * off-thread JIT worker here; the worker no longer exists.) */ psx_netplay_shutdown(); -@@ -1607,6 +2302,8 @@ +@@ -1607,6 +2304,8 @@ psx_sdl_audio_close(sdl_audio_device); /* stops the pull callback */ sdl_audio_device = 0; } @@ -1087,7 +1096,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } close_controller(); debug_server_shutdown(); -@@ -1615,6 +2312,7 @@ +@@ -1615,6 +2314,7 @@ /* Tear down the game window/GL/audio after a lobby soft-return. Leaves SDL * subsystems and the lobby WebSocket intact for the next launcher session. */ static void teardown_game_session_keep_lobby(void) { @@ -1095,7 +1104,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 netplay_host_present_restore(); psx_netplay_shutdown(); memcard_flush_all(); -@@ -1623,6 +2321,8 @@ +@@ -1623,6 +2323,8 @@ psx_sdl_audio_close(sdl_audio_device); sdl_audio_device = 0; } @@ -1104,7 +1113,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (s_drc_ready) { rab_free(&s_drc); s_drc_ready = false; } close_controller(); if (g_vk_active) { -@@ -1637,6 +2337,7 @@ +@@ -1637,6 +2339,7 @@ if (sdl_renderer) { SDL_DestroyRenderer(sdl_renderer); sdl_renderer = nullptr; } if (sdl_window) { SDL_DestroyWindow(sdl_window); sdl_window = nullptr; } if (sdl_pixel_buf) { std::free(sdl_pixel_buf); sdl_pixel_buf = nullptr; } @@ -1112,7 +1121,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 psx_lobby_set_ready(0); psx_lobby_clear_launch_pending(); psx_clear_return_to_lobby(); -@@ -1653,6 +2354,24 @@ +@@ -1653,6 +2356,24 @@ } } @@ -1137,7 +1146,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Fade-in state: samples of rising ramp still to apply after an unmute. * Consumed by sdl_audio_pump across however many pump calls it spans. * MUST fit sdl_audio_buf (2048 frames): the fade-out tail renders this many -@@ -1661,7 +2380,7 @@ +@@ -1661,7 +2382,7 @@ static int sdl_audio_fadein_left = 0; static void sdl_audio_pump(bool discard_output = false) { @@ -1146,7 +1155,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 const uint32_t bytes_per_frame = sizeof(int16_t) * 2u; const bool legacy = audio_legacy_mode(); -@@ -1751,6 +2470,8 @@ +@@ -1751,6 +2472,8 @@ sdl_audio_fadein_left -= ramp; } if (legacy) { @@ -1155,7 +1164,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* T3 tap: the exact post-fade bytes handed to the host audio queue. */ audio_trace_pcm(AUDIO_TAP_HOST, sdl_audio_buf, frames); psx_sdl_audio_queue(sdl_audio_device, sdl_audio_buf, -@@ -1883,6 +2604,7 @@ +@@ -1883,6 +2606,7 @@ g_runtime_perf.frequency = SDL_GetPerformanceFrequency(); if (!g_runtime_perf.frequency) g_runtime_perf.frequency = 1; @@ -1163,7 +1172,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (const char *interval = std::getenv("PSX_RUNTIME_PERF_DIAG_MS")) { char *tail = nullptr; long requested = std::strtol(interval, &tail, 10); -@@ -2026,12 +2748,32 @@ +@@ -2026,12 +2750,32 @@ if (end >= start) *total += end - start; } @@ -1196,7 +1205,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (!g_runtime_perf.enabled) return; uint64_t now = SDL_GetPerformanceCounter(); -@@ -2052,12 +2794,31 @@ +@@ -2052,12 +2796,31 @@ uint64_t overlay_load_us = 0, overlay_load_max_us = 0, overlay_load_last_us = 0; overlay_loader_get_load_timing(&overlay_load_us, &overlay_load_max_us, &overlay_load_last_us); @@ -1228,7 +1237,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 for (int i = 0; i < 6; i++) last_up[i] = up[i]; have_last = true; return; -@@ -2072,6 +2833,14 @@ +@@ -2072,6 +2835,14 @@ "cpu=%.1f tex=%.1f draw=%.1f ms/s; " "work guest=%.1f pacer=%.1f autocapture=%.1f provider_poll=%.1f ms/s, " "dirty=%.0f insn/s %.0f dispatch/s; " @@ -1243,7 +1252,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 "overlay native=+%llu interp=+%llu hot_native=0x%08X/+%llu " "shadow=+%llu div=+%llu first_div=0x%08X " "loads=+%u revalidations=+%u " -@@ -2096,6 +2865,26 @@ +@@ -2096,6 +2867,26 @@ runtime_perf_ticks_ms(current.provider_poll_ticks - last.provider_poll_ticks) / dt, (double)(current.dirty_insns - last.dirty_insns) / dt, (double)(current.dirty_dispatches - last.dirty_dispatches) / dt, @@ -1270,7 +1279,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 (unsigned long long)(current.overlay_native - last.overlay_native), (unsigned long long)(current.overlay_interp - last.overlay_interp), current.overlay_hot_native_pc, -@@ -2118,11 +2907,13 @@ +@@ -2118,11 +2909,13 @@ last_underruns = underruns; last_overflows = overflows; last_overlay_load_us = overlay_load_us; @@ -1285,7 +1294,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 { /* Tag audio events with the vblank frame counter. */ extern uint64_t s_frame_count; audio_trace_note_frame((uint32_t)s_frame_count); -@@ -2145,11 +2936,14 @@ +@@ -2145,11 +2938,14 @@ spu_render(sdl_audio_buf, tail); sdl_audio_gain_ramp(sdl_audio_buf, tail, g0, 0.0f); audio_trace_event(AUDIO_EV_MUTE, (uint32_t)tail, 0); @@ -1302,7 +1311,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 psx_sdl_audio_lock(sdl_audio_device); rab_push(&s_drc, sdl_audio_buf, tail); psx_sdl_audio_unlock(sdl_audio_device); -@@ -2506,6 +3300,13 @@ +@@ -2506,6 +3302,13 @@ static void load_input_config(const char* argv0) { set_default_controller_mapping(); @@ -1316,7 +1325,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 namespace fs = std::filesystem; fs::path config_path = exe_dir_from_argv(argv0) / "input.ini"; std::error_code ec; -@@ -2557,11 +3358,6 @@ +@@ -2557,11 +3360,6 @@ for (auto& entry : controller_map) entry.sources.clear(); } @@ -1328,7 +1337,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } static void close_player(PlayerInput& p) { -@@ -2691,12 +3487,26 @@ +@@ -2691,12 +3489,26 @@ } } @@ -1356,7 +1365,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 return psx_keybinds_pad_word(keys, player); } -@@ -2770,7 +3580,7 @@ +@@ -2770,7 +3582,7 @@ /* Keyboard analog: the configurable left/right stick-direction binds * (default = arrow keys on the LEFT stick; RIGHT stick unbound), so the * old keyboard analog behaviour is preserved unless the user rebinds. */ @@ -1365,7 +1374,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 psx_keybinds_sticks(keys, player, out); return; } -@@ -2839,7 +3649,7 @@ +@@ -2839,7 +3651,7 @@ } } if (p.kind == 1 || dev_any) { @@ -1374,7 +1383,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (psx_keybinds_dpad_active(keys, player)) return true; } return false; -@@ -3028,7 +3838,7 @@ +@@ -3028,7 +3840,7 @@ * sticks onto the analog stick, so an analog-mode P1 steers from whatever * is plugged in (P1 binds). */ if (dev_here && eff_analog) { @@ -1383,7 +1392,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 psx_keybinds_sticks(keys, 1, st); dev_any_controller_sticks(st); } -@@ -3095,6 +3905,16 @@ +@@ -3095,6 +3907,16 @@ * onto local_slot (host→sim P1, guest→sim P2). Never writes SIO. Exclusive * capture — no keyboard-all / all-controllers merge — so peers hash-agree. */ static void capture_local_human_pad(PsxNetPad* out) { @@ -1400,7 +1409,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 int idx = psx_netplay_input_player(); if (idx < 0 || idx > 1) idx = 0; if (!capture_pad_slot_exclusive(idx, out)) { -@@ -3228,11 +4048,12 @@ +@@ -3228,11 +4050,12 @@ if (!g_headless) { SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1414,7 +1423,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #if defined(PSX_SDL3) ev.key.key == SDLK_ESCAPE) { #else -@@ -3261,6 +4082,24 @@ +@@ -3261,6 +4084,24 @@ static void sample_pad_into_sio(int override) { if (override >= 0) { apply_input_override_to_sio(override); @@ -1439,7 +1448,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 return; } for (int s = 0; s < 2; s++) { -@@ -3482,8 +4321,51 @@ +@@ -3482,8 +4323,51 @@ } } @@ -1491,7 +1500,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #ifndef PSX_NO_DEBUG_TOOLS debug_server_set_fmv_quiet(mdec_recently_active(2)); /* Debug server: pause gate, poll commands, record frame, check watchpoints. */ -@@ -3582,6 +4464,7 @@ +@@ -3582,6 +4466,7 @@ /* Pump SDL events to prevent window freeze. */ SDL_Event ev; while (SDL_PollEvent(&ev)) { @@ -1499,7 +1508,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (ev.type == SDL_QUIT) { if (psx_netplay_active()) { netplay_soft_exit("sdl_window_close"); -@@ -3603,18 +4486,34 @@ +@@ -3603,18 +4488,34 @@ close_controller(); refresh_player_devices(); } @@ -1535,7 +1544,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Save states: Shift+F1-F12 = save slot 0-11, F1-F12 = load. * (F11 is a save slot per the user's spec, so fullscreen is * Alt+Enter / Cmd+Ctrl+F only — no F11.) */ -@@ -3649,21 +4548,34 @@ +@@ -3649,21 +4550,34 @@ * detects "currently fullscreen, either mode". */ else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { @@ -1581,7 +1590,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } /* Sample each player's device and feed the matching SIO pad slot. -@@ -3697,6 +4609,9 @@ +@@ -3697,6 +4611,9 @@ } } netplay_tail(override); @@ -1591,7 +1600,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (psx_netplay_active()) { psx_netplay_finish_frame(); } else if (g_headless) { -@@ -3704,6 +4619,9 @@ +@@ -3704,6 +4621,9 @@ } else { sample_pad_into_sio(override); } @@ -1601,7 +1610,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ -@@ -3789,16 +4707,21 @@ +@@ -3789,16 +4709,21 @@ } } @@ -1627,7 +1636,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 netplay_tail.skip_pace(); return; } -@@ -3813,7 +4736,7 @@ +@@ -3813,7 +4738,7 @@ } #endif @@ -1636,7 +1645,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 * SDL_RenderPresent. The recompiled BIOS still advances simulated * cycles every vblank, so the BIOS proceeds at whatever rate the host * CPU sustains without graphics-driver vsync overhead. Present once -@@ -3822,7 +4745,8 @@ +@@ -3822,7 +4747,8 @@ const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; const int TURBO_PRESENT_EVERY = 30; @@ -1646,7 +1655,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; if (turbo_skip != 0) { netplay_tail.skip_pace(); -@@ -3916,30 +4840,12 @@ +@@ -3916,30 +4842,12 @@ /* Mod hooks. Run after all normal input sampling. */ mod_call_frame_hooks(); @@ -1678,7 +1687,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* ---- Display from our VRAM ---- */ uint32_t w = 0, h = 0; -@@ -3963,7 +4869,8 @@ +@@ -3963,7 +4871,8 @@ /* After savestate load, always restage once even if we blanked * earlier in the session — otherwise the window keeps a stale * pre-load frame while vblanks (and FPS) keep advancing. */ @@ -1688,7 +1697,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 s_disabled_frame_presented = true; s_force_present_after_load = false; if (g_gl_active) { -@@ -3988,7 +4895,7 @@ +@@ -3988,7 +4897,7 @@ * of truth, shared with the GTE/GPU squash so content and present stay * locked: we squash IFF we stretch. depth24 always classifies as FMV * even if the ws layer is not engaged yet (4:3 titles). */ @@ -1697,7 +1706,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* MDEC movies are already decoded at their authored cadence and are * CPU/upload heavy. High-refresh crossfades only contend with decoding * and can starve audio, so present native-4:3/MDEC phases directly. -@@ -3996,19 +4903,25 @@ +@@ -3996,19 +4905,25 @@ * decode; the activity stamp also covers authentic 4:3 configurations. */ if (g_gl_active) gl_renderer_set_interpolation_suspended( @@ -1729,7 +1738,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Native-wide invariant: canonical (320-wide) content is NEVER * stretched across the wide window — a game frame that cannot present -@@ -4045,6 +4958,22 @@ +@@ -4045,6 +4960,22 @@ if (gl_renderer_present_wide_fbo((int)di.display_x, (int)di.display_y, (int)h, g_video_aa ? 1 : 0)) return; @@ -1752,7 +1761,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } else { gl_renderer_present_vram((int)di.display_x, (int)di.display_y, (int)present_w, (int)h, g_video_aa ? 1 : 0, -@@ -4142,6 +5071,7 @@ +@@ -4142,6 +5073,7 @@ h * (uint32_t)active_scale, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); @@ -1760,7 +1769,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Frame blending (CRT-persistence masker for 30fps double-buffered * content). Some games (e.g. Crash Bash menus/characters) leave a * dynamic object in only one of the two display buffers per 30fps cycle, -@@ -4183,6 +5113,7 @@ +@@ -4183,6 +5115,7 @@ } } } @@ -1768,7 +1777,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } /* Update only the active display rectangle. The backing texture is sized -@@ -4245,7 +5176,7 @@ +@@ -4245,7 +5178,7 @@ if (!g_present_vsync_disabled && present_ms > 250) { g_present_slow_count++; if (g_present_slow_count >= 3 && @@ -1777,7 +1786,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 g_present_vsync_disabled = 1; } } -@@ -4867,9 +5798,11 @@ +@@ -4867,9 +5800,11 @@ #endif int main(int argc, char** argv) { @@ -1792,7 +1801,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 std::fprintf(stderr, "psxrecomp: main() entered\n"); std::fflush(stderr); -@@ -5047,7 +5980,11 @@ +@@ -5047,7 +5982,11 @@ bool ws_offered = true; /* game.toml [widescreen] offer; false hides the launcher toggle + clamps 4:3 */ bool ws_ultrawide_offered = false; bool ws_adaptive_view_supported = false; @@ -1804,7 +1813,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 bool skip_fmv_offered = true; bool turbo_loads_offered = true; bool vulkan_offered = false; /* game.toml [video] offer_vulkan; developer opt-in for launcher visibility */ -@@ -5084,6 +6021,7 @@ +@@ -5084,6 +6023,7 @@ * path is resolved (arm_text_image_guard). */ std::string text_guard_exe_path; uint32_t text_guard_load_addr = 0; @@ -1812,7 +1821,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (game_config_path) { try { -@@ -5143,8 +6081,10 @@ +@@ -5143,8 +6083,10 @@ g_video_aspect_den = gc.runtime.video_aspect_den; g_low_latency_input = gc.runtime.video_low_latency_input ? 1 : 0; g_video_vsync = gc.runtime.video_vsync; @@ -1823,7 +1832,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 g_fmv_skip_total_table = gc.runtime.video_fmv_skip_total_table; g_fmv_skip_movie_id = gc.runtime.video_fmv_skip_movie_id; if (gc.runtime.video_fmv_skip_end_total) -@@ -5308,8 +6248,10 @@ +@@ -5308,8 +6250,10 @@ ws_offered = gc.ws_offered; ws_ultrawide_offered = gc.ws_ultrawide_offered; ws_adaptive_view_supported = gc.ws_adaptive_view; @@ -1834,7 +1843,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 skip_fmv_offered = gc.runtime.video_offer_skip_fmv; turbo_loads_offered = gc.runtime.offer_turbo_loads; vulkan_offered = gc.vulkan_offered; -@@ -5363,6 +6305,7 @@ +@@ -5363,6 +6307,7 @@ * user's disc image instead. */ text_guard_exe_path = gc.exe_path.string(); text_guard_load_addr = gc.load_address; @@ -1842,7 +1851,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* HLE-tier scheduler subsystem replacement default (env * PSX_HLE_SCHEDULER still wins; latched at first dispatch). */ psx_hle_scheduler_set_default(gc.runtime.hle_scheduler ? 1 : 0); -@@ -5567,6 +6510,10 @@ +@@ -5567,6 +6512,10 @@ } } @@ -1853,7 +1862,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (!game_name.empty()) s_picker_game_name = game_name; /* Layer the launcher-written settings.toml (next to the exe) over the -@@ -5580,6 +6527,9 @@ +@@ -5580,6 +6529,9 @@ { std::filesystem::path settings_path = exe_dir_from_argv(argv[0]) / "settings.toml"; @@ -1863,7 +1872,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 #if defined(RECOMP_LAUNCHER) g_lnch_settings_path = settings_path; #endif -@@ -5640,6 +6590,10 @@ +@@ -5640,6 +6592,10 @@ * Config + env own it until a launcher toggle exists; if one is * added, restore this line together with the UI. The seed write * below still persists the effective value for inspection. */ @@ -1874,7 +1883,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (us.has_bios_hle) bios_hle_requested = us.bios_hle; if (us.has_fullscreen) g_fullscreen = us.fullscreen; if (us.has_aspect_ratio) { -@@ -5648,6 +6602,10 @@ +@@ -5648,6 +6604,10 @@ } if (us.has_adaptive_view) g_ws_adaptive_view = us.adaptive_view; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; @@ -1885,7 +1894,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Bundled BIOS: ignore any persisted bios_path (same clamp-as-well- * as-hide treatment as lock_mode / ws_offered below) so a stale * settings.toml or launcher-less build can never point a bundled -@@ -5682,10 +6640,30 @@ +@@ -5682,10 +6642,30 @@ if (us.has_deadzone) resolved_deadzone = us.deadzone; if (us.has_low_latency_input) g_low_latency_input = us.low_latency_input ? 1 : 0; if (us.has_vsync) g_video_vsync = us.vsync; @@ -1916,7 +1925,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (us.has_parappa_timing_mode || us.has_parappa_timing_extra_early || us.has_parappa_timing_extra_late) { parappa_apply_timing_setting( -@@ -5782,25 +6760,65 @@ +@@ -5782,25 +6762,65 @@ g_video_aspect_den = ws_offered ? 9 : 3; } if (!ws_adaptive_view_supported) g_ws_adaptive_view = false; @@ -1987,7 +1996,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 strcmp(e, "1") == 0) { g_frame_interpolation_blend_default = PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE; -@@ -5810,6 +6828,11 @@ +@@ -5810,6 +6830,11 @@ } g_frame_interpolation_blend = g_frame_interpolation_blend_default; } @@ -1999,7 +2008,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 { const char *mode_env = std::getenv("PSX_PARAPPA_TIMING_MODE"); const char *early_env = std::getenv("PSX_PARAPPA_TIMING_EXTRA_EARLY"); -@@ -5916,7 +6939,13 @@ +@@ -5916,7 +6941,13 @@ if (want_launcher) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) == 0) { int lr = 2; /* 0 = launch, 1 = quit, 2 = unavailable */ @@ -2014,7 +2023,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 seed.renderer = g_video_renderer; seed.has_renderer = true; seed.supersampling = g_video_scale; seed.has_supersampling = true; seed.antialiasing = g_video_aa; seed.has_antialiasing = true; -@@ -6415,6 +7444,12 @@ +@@ -6415,6 +7446,12 @@ g_turbo_load_release_frames = TURBO_LOADS_RELEASE_FRAMES; if (!turbo_loads_offered) g_turbo_loads_enabled = 0; @@ -2027,7 +2036,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); if (g_mod_controller_mode_override[0] >= 0) -@@ -6527,6 +7562,22 @@ +@@ -6527,6 +7564,22 @@ gr_set_scale(g_video_scale); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ gr_set_texture_filter(g_video_texfilter); @@ -2050,7 +2059,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Display aspect. Identity at the default 4:3. The present letterbox uses * this aspect; native-wide fills it with a genuinely wider frame (no * stretch), squash mode stretches the 4:3 frame into it. */ -@@ -6633,6 +7684,7 @@ +@@ -6633,6 +7686,7 @@ * the local EXE file (dev checkouts) and the disc image (every install). */ if (game_config_path) arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, @@ -2058,7 +2067,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 disc_path_str); mod_runtime_enable_disc_patches(); { -@@ -6691,6 +7743,7 @@ +@@ -6691,6 +7745,7 @@ if (g_headless) { std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); @@ -2066,7 +2075,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } else { /* ---- SDL init ---- */ /* Scale quality governs SDL's logical-size -> window scaling. Linear when -@@ -6780,6 +7833,17 @@ +@@ -6780,6 +7835,17 @@ std::fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError()); return 1; } @@ -2084,7 +2093,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank -@@ -6909,6 +7973,7 @@ +@@ -6909,6 +7975,7 @@ std::fprintf(stderr, "failed to allocate %dx staging buffer\n", g_video_scale); return 1; } @@ -2092,7 +2101,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 if (!g_gl_active && !g_vk_active) { sdl_texture = SDL_CreateTexture( -@@ -6924,6 +7989,12 @@ +@@ -6924,6 +7991,12 @@ SDL_SetTextureScaleMode(sdl_texture, g_video_aa ? SDL_ScaleModeLinear : SDL_ScaleModeNearest); } @@ -2105,7 +2114,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 } /* Register vblank presentation callback. */ -@@ -7012,6 +8083,9 @@ +@@ -7012,6 +8085,9 @@ bios_hle_requested = (e[0] && e[0] != '0'); if (const char* e = std::getenv("PSX_BIOS_HLE_KEEP_INTRO")) bios_hle_keep_intro = (e[0] && e[0] != '0'); @@ -2115,7 +2124,7 @@ index 81e048307ec9dd9fc7234a1e6285042ffab16457..45f78f916c5bf709ece6b6a89f2ba9e3 PsxBiosHleRequest req; req.bios_hle = bios_hle_requested ? 1 : 0; -@@ -7062,6 +8136,10 @@ +@@ -7062,6 +8138,10 @@ if (game_entry_pc != 0) { savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc); diff --git a/src/game/host.cpp b/src/game/host.cpp index 94da2ad..2c92ef1 100644 --- a/src/game/host.cpp +++ b/src/game/host.cpp @@ -5,6 +5,7 @@ #include "game/render/far_rendering.h" #include "game/render/precision_routes.h" #include "game/session/capsule.h" +#include "game/session/diagnostics.h" #include "gpu.h" #include @@ -18,6 +19,7 @@ extern "C" int disruptor_grown_quad_sources(uint32_t command_address, const uint uint32_t source_word[4]); bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) { + disruptor_diagnostics_configure(settings.has_detailed_logging && settings.detailed_logging); if (settings.has_language_disc && !std::getenv("PSX_DISRUPTOR_LANGUAGE_DISC")) disruptor_language_set_disc(settings.language_disc.c_str()); return settings.has_skip_intro && settings.skip_intro && !std::getenv("PSX_DISRUPTOR_SKIP_INTRO"); diff --git a/src/game/session/diagnostics.cpp b/src/game/session/diagnostics.cpp new file mode 100644 index 0000000..d07996a --- /dev/null +++ b/src/game/session/diagnostics.cpp @@ -0,0 +1,246 @@ +#include "game/session/diagnostics.h" + +#include "psx_sdl.h" +#include "game/frame/frame_rate.h" + +#include +#include +#include +#include +#include +#include + +extern "C" SDL_Window *sdl_window; +extern "C" void mod_register_frame_hook(void (*hook)(void)); +extern "C" void gl_renderer_set_debug_messages(int enabled); +extern "C" int gl_renderer_debug_messages_active(void); +extern "C" int gl_renderer_debug_message_take(char *text, int size, unsigned *id, int *severity, int *context, unsigned *lost); +extern "C" int gpu_ws_displayed_flat(void); +extern "C" int gpu_ws_displayed_menu(void); + +namespace disruptor::diagnostics { +namespace { + +constexpr double pulse_seconds = 10.0; +constexpr double device_seconds = 1.0; +constexpr size_t driver_kinds = 64; +constexpr int driver_messages_a_frame = 16; // the renderer's queue: a driver that never stops must not hold a frame + +struct Session { + bool detailed = false; + bool asked_at_start = false; + bool begun = false; + bool announced = false; + bool devices_known = false; + double now = 0.0, last_frame = 0.0, next_pulse = 0.0, next_devices = 0.0; + Stretch stretch; + std::vector devices; + DriverMessages driver; + uint32_t lost = 0; +}; + +Session g_session; +const std::chrono::steady_clock::time_point g_began = std::chrono::steady_clock::now(); + +void say(const std::string& text) { std::printf("disruptor: +%.1fs %s\n", g_session.now, text.c_str()); } + +const char* device_name(int index) { +#if defined(PSX_SDL3) + return SDL_GetJoystickNameForID(SDL_JoystickGetDeviceInstanceID(index)); +#else + return SDL_JoystickNameForIndex(index); +#endif +} + +std::vector devices_now() { + std::vector names; + for (int index = 0, count = SDL_NumJoysticks(); index < count; ++index) { + const char* name = device_name(index); + names.emplace_back(name && name[0] ? name : "unnamed device"); + } + std::sort(names.begin(), names.end()); + return names; +} + +const char* driver_text(int state) { + switch (state) { + case DISRUPTOR_DRIVER_ON: return "on"; + case DISRUPTOR_DRIVER_NEXT_LAUNCH: return "from the next launch"; + case DISRUPTOR_DRIVER_REFUSED: return "not offered by this driver"; + default: return "off"; + } +} + +Moment moment_now() { + Moment moment; + DisruptorFrameRateWindow window{}; + if (disruptor_frame_rate_last_window(&window)) { + moment.game_frames = window.frames; + moment.game_vblanks = window.vblanks; + moment.late = window.late_frames; + } + moment.screen = gpu_ws_displayed_menu() ? "menu" : gpu_ws_displayed_flat() ? "flat screen" : "world"; + const SDL_WindowFlags flags = sdl_window ? SDL_GetWindowFlags(sdl_window) : 0; + moment.focused = (flags & SDL_WINDOW_INPUT_FOCUS) != 0; + moment.minimized = (flags & SDL_WINDOW_MINIMIZED) != 0; + return moment; +} + +void take_driver_messages() { + char text[200]; + for (int taken = 0; taken < driver_messages_a_frame; ++taken) { + unsigned id = 0, lost = 0; + int severity = 0, context = 0; + const int given = gl_renderer_debug_message_take(text, sizeof text, &id, &severity, &context, &lost); + g_session.lost += lost; + if (!given) return; + if (const std::string line = g_session.driver.line(id, severity, context, text); !line.empty()) say(line); + } +} + +void look_at_devices() { + std::vector names = devices_now(); + if (!g_session.devices_known && names.empty()) say("controllers: none"); + for (const auto& line : device_changes(g_session.devices, names)) say(line); + g_session.devices = std::move(names); + g_session.devices_known = true; +} + +void frame_hook() { frame_at(std::chrono::duration(std::chrono::steady_clock::now() - g_began).count()); } + +struct Registration { + Registration() { + const char* forced = std::getenv("PSX_DISRUPTOR_DETAILED_LOGGING"); + if (forced && forced[0]) disruptor_diagnostics_configure(0); + mod_register_frame_hook(frame_hook); + } +}; + +Registration g_registration; + +} // namespace + +void Stretch::frame(double seconds) { + ++frames; + waited += seconds; + longest = std::max(longest, seconds); +} + +std::string pulse_line(const Stretch& stretch, const Moment& moment) { + char text[320]; + std::snprintf(text, sizeof text, + "pulse: %u frames in %.1f s, %.1f ms a frame, longest %.1f ms, game %u frames in %u vblanks with %u late, %s, %s", + stretch.frames, stretch.waited, stretch.frames ? stretch.waited / stretch.frames * 1000.0 : 0.0, + stretch.longest * 1000.0, moment.game_frames, moment.game_vblanks, moment.late, moment.screen, + moment.focused ? "focused" : "not focused"); + std::string line = text; + if (moment.minimized) line += ", minimized"; + if (moment.repeated) line += ", driver messages repeated " + std::to_string(moment.repeated); + if (moment.lost) line += ", driver messages lost " + std::to_string(moment.lost); + return line; +} + +std::vector device_changes(const std::vector& before, const std::vector& now) { + std::vector gone, come, lines; + std::set_difference(before.begin(), before.end(), now.begin(), now.end(), std::back_inserter(gone)); + std::set_difference(now.begin(), now.end(), before.begin(), before.end(), std::back_inserter(come)); + for (const auto& name : gone) lines.push_back("controller removed: " + name); + for (const auto& name : come) lines.push_back("controller connected: " + name); + return lines; +} + +std::string DriverMessages::line(uint32_t id, int severity, int context, std::string_view text) { + const bool known = said_.contains(id); + if ((!known && said_.size() >= driver_kinds) || (known && said_[id] >= printed_of_a_kind)) { + ++repeated_; + return {}; + } + ++said_[id]; + while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) text.remove_suffix(1); + std::string line = context ? "driver (presenter context, " : "driver (main context, "; + line += severity >= 2 ? "high, id " : "medium, id "; + line += std::to_string(id) + "): "; + line += text; + return line; +} + +uint32_t DriverMessages::take_repeated() { + const uint32_t repeated = repeated_; + repeated_ = 0; + return repeated; +} + +void frame_at(double seconds) { + Session& session = g_session; + const double waited = seconds - session.last_frame; + session.now = seconds; + session.last_frame = seconds; + if (!session.begun) { + session.begun = true; + std::printf("disruptor: first frame %.1f s after the start\n", seconds); + } + if (!session.detailed) { + session.announced = false; + return; + } + if (!session.announced) { + session.announced = true; + session.devices_known = false; + session.devices.clear(); + session.stretch = {}; + session.next_pulse = seconds + pulse_seconds; + session.next_devices = seconds; + say(std::string("detailed logging on, driver messages ") + driver_text(disruptor_diagnostics_driver_state())); + } else { + session.stretch.frame(waited); + } + take_driver_messages(); + if (seconds >= session.next_devices) { + session.next_devices = seconds + device_seconds; + look_at_devices(); + } + if (seconds >= session.next_pulse) { + Moment moment = moment_now(); + moment.repeated = session.driver.take_repeated(); + moment.lost = session.lost; + say(pulse_line(session.stretch, moment)); + session.stretch = {}; + session.lost = 0; + session.next_pulse = seconds + pulse_seconds; + } +} + +} // namespace disruptor::diagnostics + +using disruptor::diagnostics::g_session; + +extern "C" { + +void disruptor_diagnostics_configure(int saved) { + const char* forced = std::getenv("PSX_DISRUPTOR_DETAILED_LOGGING"); + g_session.detailed = forced && forced[0] ? forced[0] != '0' : saved != 0; + g_session.asked_at_start = g_session.detailed; + gl_renderer_set_debug_messages(g_session.detailed ? 1 : 0); +} + +int disruptor_diagnostics_detailed(void) { return g_session.detailed ? 1 : 0; } + +void disruptor_diagnostics_set_detailed(int enabled) { g_session.detailed = enabled != 0; } + +int disruptor_diagnostics_driver_state(void) { + if (!g_session.detailed) return DISRUPTOR_DRIVER_OFF; + if (gl_renderer_debug_messages_active()) return DISRUPTOR_DRIVER_ON; + return g_session.asked_at_start ? DISRUPTOR_DRIVER_REFUSED : DISRUPTOR_DRIVER_NEXT_LAUNCH; +} + +void disruptor_diagnostics_note(const char *format, ...) { + if (!g_session.detailed) return; + char text[512]; + va_list arguments; + va_start(arguments, format); + std::vsnprintf(text, sizeof text, format, arguments); + va_end(arguments); + disruptor::diagnostics::say(text); +} + +} // extern "C" diff --git a/src/game/session/diagnostics.h b/src/game/session/diagnostics.h new file mode 100644 index 0000000..33fa6ba --- /dev/null +++ b/src/game/session/diagnostics.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#include +#include +#include + +// What the game's log holds for a bug report: a few lines of every session, and more while detailed logging is on. +namespace disruptor::diagnostics { + +/// The frames of a stretch of a session, gathered a frame at a time. +struct Stretch { + uint32_t frames = 0; + double waited = 0.0; // seconds, the sum over the frames + double longest = 0.0; // seconds, the longest wait for one frame + void frame(double seconds); +}; + +/// What a pulse says beside its stretch. +struct Moment { + uint32_t game_frames = 0, game_vblanks = 0, late = 0; // the game's own last window, as the frame rate module measures it + const char* screen = "world"; + bool focused = true, minimized = false; + uint32_t repeated = 0, lost = 0; // driver messages said too often to print, and ones the queue had no room for +}; + +/// The log's line for a stretch: frames the frame loop finished, their mean and longest wait, and the moment. +std::string pulse_line(const Stretch& stretch, const Moment& moment); + +/// A line for each name that left `before` and each that came with `now`. A name stands as often as a device has it. +std::vector device_changes(const std::vector& before, const std::vector& now); + +/// The graphics driver's messages, each kind printed a few times and counted from then on. +class DriverMessages { +public: + static constexpr uint32_t printed_of_a_kind = 3; + /// The line to print, or empty for a kind said `printed_of_a_kind` times already. + std::string line(uint32_t id, int severity, int context, std::string_view text); + /// How many were left unprinted since the last call. + uint32_t take_repeated(); + +private: + std::map said_; + uint32_t repeated_ = 0; +}; + +/// One step of the session's log at `seconds` since the process began. The game's frame hook calls it with the clock. +void frame_at(double seconds); + +} // namespace disruptor::diagnostics + +extern "C" { +#endif + +/* Driver messages: 0 not asked, 1 printed, 2 asked after the contexts were made, so from the next launch, 3 refused. */ +enum { DISRUPTOR_DRIVER_OFF = 0, DISRUPTOR_DRIVER_ON = 1, DISRUPTOR_DRIVER_NEXT_LAUNCH = 2, DISRUPTOR_DRIVER_REFUSED = 3 }; + +/* At start, before the game's window: the saved setting, which PSX_DISRUPTOR_DETAILED_LOGGING replaces for a run. */ +void disruptor_diagnostics_configure(int saved); +int disruptor_diagnostics_detailed(void); +void disruptor_diagnostics_set_detailed(int enabled); +int disruptor_diagnostics_driver_state(void); +/* A line of the detailed log with its time, printf style. Nothing while detailed logging is off. */ +void disruptor_diagnostics_note(const char *format, ...); + +#ifdef __cplusplus +} +#endif diff --git a/src/game/settings/game_user_settings.h b/src/game/settings/game_user_settings.h index 29b0d0a..3135bd5 100644 --- a/src/game/settings/game_user_settings.h +++ b/src/game/settings/game_user_settings.h @@ -27,6 +27,7 @@ struct GameUserSettings { bool has_occlusion_strength = false; int occlusion_strength = 80; // percent, 0..300 bool has_gap_filling = false; bool gap_filling = true; bool has_depth_sorted_world = false; bool depth_sorted_world = false; + bool has_detailed_logging = false; bool detailed_logging = false; // An index into kDisruptorDiagnosticViews. A session's own: at start only a capsule's replay takes it from the file. bool has_diagnostic_view = false; int diagnostic_view = 0; }; diff --git a/src/game/settings/options.cpp b/src/game/settings/options.cpp index 15325e4..32ffc85 100644 --- a/src/game/settings/options.cpp +++ b/src/game/settings/options.cpp @@ -188,6 +188,10 @@ constexpr Option option_list[] = { write<&Settings::has_master_volume, &Settings::master_volume>), toggle<&Settings::has_audio_muted, &Settings::audio_muted>("audio.muted", "Mute all audio", 0, "Silences the game and keeps the volume setting."), + toggle<&Settings::has_detailed_logging, &Settings::detailed_logging>( + "disruptor.detailed_logging", "Detailed logging", 0, + "Writes more into the game's log for a bug report: how the frames ran, settings changed in the menu, " + "controllers coming and going, the graphics driver's complaints. The driver's part starts with the next launch."), toggle<&Settings::has_ambient_occlusion, &Settings::ambient_occlusion>( "disruptor.ambient_occlusion", "Ambient occlusion", 0, @@ -218,4 +222,17 @@ bool applies(const Option& option, const std::function& on) }); } +int stored(const Settings& settings, const Option& option) { + return option.saved(settings) ? option.get(settings) : option.initial; +} + +std::string describe(const Settings& settings) { + std::string text; + for (const auto& option : option_list) { + if (!text.empty()) text += ' '; + text += std::string(option.key) + '=' + std::to_string(stored(settings, option)); + } + return text; +} + } // namespace disruptor::settings diff --git a/src/game/settings/options.h b/src/game/settings/options.h index c939a5a..17862c7 100644 --- a/src/game/settings/options.h +++ b/src/game/settings/options.h @@ -4,6 +4,7 @@ #include #include +#include // The settings a player can change, said once for the launcher and for the in-game menu. namespace disruptor::settings { @@ -34,5 +35,9 @@ std::span options(); const Option* find_option(const char* key); // Whether every toggle the option needs is on, as `on` tells, and every toggle those need in turn. bool applies(const Option& option, const std::function& on); +// The option's value in `settings`, or the one a fresh installation runs with where the file does not name it. +int stored(const Settings& settings, const Option& option); +// Every option as `key=value` in the table's order, for the game's log. +std::string describe(const Settings& settings); } // namespace disruptor::settings diff --git a/src/game/settings/saved.cpp b/src/game/settings/saved.cpp index c829994..31f9ba1 100644 --- a/src/game/settings/saved.cpp +++ b/src/game/settings/saved.cpp @@ -140,6 +140,10 @@ void read_game_user_settings(const toml::value& doc, UserSettings& s) { } } }); + if (d.contains("detailed_logging")) try_get([&]{ + s.detailed_logging = toml::find(d, "detailed_logging"); + s.has_detailed_logging = true; + }); } } @@ -165,7 +169,7 @@ void write_game_user_settings(std::ostream& f, const UserSettings& s) { s.has_high_precision_camera || s.has_geometry_correction || s.has_perspective_textures || s.has_frame_unlock || s.has_improved_shadows || s.has_ambient_occlusion || s.has_occlusion_reach || s.has_occlusion_strength || - s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || + s.has_gap_filling || s.has_depth_sorted_world || s.has_diagnostic_view || s.has_detailed_logging || s.has_language_discs || s.has_language_disc || s.has_skip_intro || s.has_hud_scale) { @@ -230,6 +234,8 @@ void write_game_user_settings(std::ostream& f, const UserSettings& s) { f << "depth_sorted_world = " << (s.depth_sorted_world ? "true" : "false") << "\n"; if (s.has_diagnostic_view && s.diagnostic_view >= 0 && s.diagnostic_view < kDisruptorDiagnosticViewCount) f << "diagnostic_view = " << quoted(kDisruptorDiagnosticViews[s.diagnostic_view]) << "\n"; + if (s.has_detailed_logging) + f << "detailed_logging = " << (s.detailed_logging ? "true" : "false") << "\n"; } } diff --git a/src/game/ui/menu/menu.h b/src/game/ui/menu/menu.h index 9263823..1df083c 100644 --- a/src/game/ui/menu/menu.h +++ b/src/game/ui/menu/menu.h @@ -15,6 +15,7 @@ #include "game/render/far_rendering.h" #include "game/frame/frame_rate.h" #include "game/session/capsule.h" +#include "game/session/diagnostics.h" #include "game/session/intro_skip.h" #include "game/input/mouse_aim.h" #include "game/frame/present_rate.h" @@ -143,6 +144,7 @@ void mark_occlusion(bool value); void mark_occlusion_shape(int reach, int strength); void mark_gap_filling(bool value); void mark_world_depth(bool value); +void mark_detailed_logging(bool value); void apply_diagnostic_view(int view); void mark_diagnostic_view(int value); void apply_smooth_turn(bool enabled); diff --git a/src/game/ui/menu/preferences.cpp b/src/game/ui/menu/preferences.cpp index 912e453..f7eefd9 100644 --- a/src/game/ui/menu/preferences.cpp +++ b/src/game/ui/menu/preferences.cpp @@ -176,6 +176,8 @@ void mark_gap_filling(bool value) { mark("disruptor.gap_filling", value); } void mark_world_depth(bool value) { mark("disruptor.depth_sorted_world", value); } +void mark_detailed_logging(bool value) { mark("disruptor.detailed_logging", value); } + // Views 1 to 4 are layers of the occlusion pass, 5 is the mesh drawn over in-between frames. void apply_diagnostic_view(int view) { gl_renderer_set_ssao_view(view >= 1 && view <= 4 ? view : 0); @@ -242,6 +244,8 @@ bool flush_preferences() { return false; } + for (const Option *option : g_preferences.changed) + disruptor_diagnostics_note("setting %s = %d", option->key, option->get(g_preferences.pending)); g_preferences.clean(); g_preferences.save_failed = false; g_preferences.status = "Settings saved."; @@ -409,6 +413,8 @@ void apply_pending_preferences() { gl_renderer_set_gap_fill(pending.gap_filling ? 1 : 0); if (changed("disruptor.depth_sorted_world")) gl_renderer_set_world_depth(pending.depth_sorted_world ? 1 : 0); + if (changed("disruptor.detailed_logging")) + disruptor_diagnostics_set_detailed(pending.detailed_logging ? 1 : 0); if (g_preferences.diagnostic_view_changed) apply_diagnostic_view(pending.diagnostic_view); if (changed("disruptor.high_precision_camera")) @@ -438,8 +444,10 @@ void load_preferences_for_session() { g_preferences.loaded = false; g_preferences.status = "settings.toml is invalid; saved menu values were not applied."; + std::printf("disruptor: settings at start: the settings file is invalid and was not applied\n"); return; } + std::printf("disruptor: settings at start: %s\n", disruptor::settings::describe(settings).c_str()); capture_base_keybinds(); apply_saved_preferences(settings); apply_pending_preferences(); diff --git a/src/game/ui/menu/tab_debug.cpp b/src/game/ui/menu/tab_debug.cpp index e11febc..d735e47 100644 --- a/src/game/ui/menu/tab_debug.cpp +++ b/src/game/ui/menu/tab_debug.cpp @@ -1,11 +1,22 @@ #include "game/ui/menu/menu.h" namespace disruptor::menu { +namespace { +constexpr const char *driver_words[] = {"off", "on", "from the next launch", "not offered by this driver"}; +} // namespace void draw_debug_tab() { - ImGui::TextDisabled("Tools for a report of a fault in the picture."); + ImGui::TextDisabled("Tools for a report of a fault."); ImGui::Spacing(); + bool detailed = disruptor_diagnostics_detailed() != 0; + if (option_toggle("disruptor.detailed_logging", &detailed)) { + disruptor_diagnostics_set_detailed(detailed ? 1 : 0); + mark_detailed_logging(detailed); + } + if (detailed) ImGui::TextDisabled("Driver messages: %s.", driver_words[disruptor_diagnostics_driver_state()]); + + ImGui::Separator(); if (ImGui::Button("Record the screen") && !gl_renderer_recording_shown()) g_menu.record_shown = true; draw_status_badge("SESSION", ImVec4(0.35f, 0.90f, 0.45f, 1.0f)); ImGui::TextDisabled( diff --git a/src/launcher/launcher_settings.cpp b/src/launcher/launcher_settings.cpp index 9b84723..0df63a2 100644 --- a/src/launcher/launcher_settings.cpp +++ b/src/launcher/launcher_settings.cpp @@ -12,7 +12,7 @@ namespace fs = std::filesystem; namespace disruptor::launcher { namespace { -constexpr const wchar_t* pages[] = {L"Display", L"Enhancements", L"Controls", L"Audio", L"Experimental"}; +constexpr const wchar_t* pages[] = {L"Display", L"Enhancements", L"Controls", L"Audio", L"Experimental", L"Troubleshooting"}; constexpr const char* rate_key = "video.frame_interpolation_fps"; constexpr Placement placed[] = { @@ -43,6 +43,7 @@ constexpr Placement placed[] = { {"disruptor.ambient_occlusion", Page::experimental}, {"disruptor.occlusion_reach", Page::experimental}, {"disruptor.occlusion_strength", Page::experimental}, + {"disruptor.detailed_logging", Page::troubleshooting}, }; constexpr PresetValue original_values[] = { @@ -58,10 +59,6 @@ constexpr const char* shipped_keys[] = { "disruptor.mouse_aim", "disruptor.modern_controls", "disruptor.vertical_look", "disruptor.geometry_correction", "disruptor.perspective_textures", "disruptor.frame_unlock", "disruptor.improved_shadows", "video.frame_interpolation"}; -int stored(const Settings& settings, const Option& option) { - return option.saved(settings) ? option.get(settings) : option.initial; -} - bool listed(const std::vector& discs, const std::string& path) { return std::find(discs.begin(), discs.end(), path) != discs.end(); } diff --git a/src/launcher/launcher_settings.h b/src/launcher/launcher_settings.h index 4b29f9f..a9f359a 100644 --- a/src/launcher/launcher_settings.h +++ b/src/launcher/launcher_settings.h @@ -15,9 +15,10 @@ using settings::Kind; using settings::Option; using settings::options; using settings::Settings; +using settings::stored; using settings::Unit; -enum class Page { display, enhancements, controls, audio, experimental }; +enum class Page { display, enhancements, controls, audio, experimental, troubleshooting }; // A row of the settings pages. The rows of a page are shown in the order they are listed. struct Placement { const char* key; Page page; }; diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp index 6dcc433..672cb3d 100644 --- a/src/launcher/main.cpp +++ b/src/launcher/main.cpp @@ -45,6 +45,8 @@ constexpr const wchar_t* page_notes[] = { L"Perspective textures and in-between frames need exact geometry.", L"Key bindings are in keybinds.ini beside the launcher.", L"", + L"", + L"Switch on, play until the problem shows, close the game, then press Report a problem.", }; fs::path executable_directory() { @@ -82,7 +84,7 @@ HANDLE start_game(const fs::path& root, const VerifiedDisc& disc) { if (input != INVALID_HANDLE_VALUE) CloseHandle(input); throw std::runtime_error("Cannot create startup.log. Put the extracted build in a writable Games folder."); } - std::string heading; + std::string heading = started_line(); for (const auto line : std::views::split(system_note(), std::string_view("\r\n"))) if (!line.empty()) heading += "launcher: " + std::string(line.begin(), line.end()) + "\r\n"; DWORD written = 0; @@ -150,6 +152,7 @@ struct App { std::atomic_bool cancelled{false}; bool busy = false, closing = false, unwritten = false; HANDLE game = nullptr; + ULONGLONG game_started = 0; VerifiedDisc verified; explicit App(fs::path folder) : root(std::move(folder)), settings(root / "settings.toml") {} @@ -305,7 +308,7 @@ struct App { // A row scrolled away with the keyboard on it would leave Tab nowhere to go from. if (HWND focus = GetFocus(); focus && GetParent(focus) == tabs && !IsWindowVisible(focus)) SetFocus(page_rows[page] > page_slots ? scroll : tabs); - SetWindowTextW(page_note, page_notes[std::clamp(page, 0, 3)]); + SetWindowTextW(page_note, page_notes[std::clamp(page, 0, static_cast(std::size(page_notes)) - 1)]); } void controls() { EnableWindow(browse, !busy && !game); @@ -373,6 +376,7 @@ struct App { void launch() { try { game = start_game(root, verified); + game_started = GetTickCount64(); say(running_text(environment_overrides(), unwritten)); SetTimer(window, 1, 500, nullptr); } catch (const std::exception& error) { @@ -707,6 +711,7 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lp app->say(tail.empty() ? std::wstring(heading) + L"\nSee GETTING_STARTED.md." : std::wstring(heading) + L"\nThe end of startup.log:\n" + tail); } + note_exit(app->root / "startup.log", code, (GetTickCount64() - app->game_started) / 1000); app->controls(); app->look(); } diff --git a/src/launcher/report.cpp b/src/launcher/report.cpp index 06b1775..6477a2a 100644 --- a/src/launcher/report.cpp +++ b/src/launcher/report.cpp @@ -5,11 +5,17 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include +#include +#include #ifndef DISRUPTOR_BUILD_VERSION #define DISRUPTOR_BUILD_VERSION "unknown" @@ -19,7 +25,11 @@ namespace fs = std::filesystem; namespace disruptor::launcher { namespace { -constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml"}; +constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml", + "psx_last_run_report.json", "psx_freeze_heartbeat.json"}; +constexpr std::wstring_view freeze_dump = L"psx_freeze_dump_"; +constexpr size_t most_freeze_dumps = 2; +constexpr std::string_view started = "launcher: started "; constexpr std::streamoff most_bytes = 4 * 1024 * 1024; // of a log's end: GitHub takes attachments of 25 MB bool separates(char letter) { return letter == '\\' || letter == '/'; } @@ -75,6 +85,83 @@ std::string narrow(std::wstring_view text) { return out; } +std::string utc_text(const SYSTEMTIME& time) { + std::array text{}; + std::snprintf(text.data(), text.size(), "%04u-%02u-%02u %02u:%02u:%02u UTC", time.wYear, time.wMonth, time.wDay, + time.wHour, time.wMinute, time.wSecond); + return text.data(); +} + +std::string listed(const fs::path& file) { + WIN32_FILE_ATTRIBUTE_DATA found{}; + if (!GetFileAttributesExW(file.c_str(), GetFileExInfoStandard, &found)) return "none"; + SYSTEMTIME written{}; + FileTimeToSystemTime(&found.ftLastWriteTime, &written); + const std::uint64_t bytes = (std::uint64_t{found.nFileSizeHigh} << 32) | found.nFileSizeLow; + return std::to_string(bytes) + " bytes, written " + utc_text(written) + + (bytes > static_cast(most_bytes) ? ", its end taken" : ""); +} + +bool plain_file(const fs::path& file) { + std::error_code missing; + return fs::is_regular_file(fs::symlink_status(file, missing)); +} + +// The unix time the last run began, from the line the launcher heads the log with. +std::optional run_began(const fs::path& log) { + constexpr std::array places = {0, 5, 8, 11, 14, 17}, widths = {4, 2, 2, 2, 2, 2}; // YYYY-MM-DD HH:MM:SS + std::string head(started.size() + 19, '\0'); + std::ifstream input(log, std::ios::binary); + input.read(head.data(), static_cast(head.size())); + if (!plain_file(log) || !head.starts_with(started)) return std::nullopt; + std::array parts{}; + for (size_t part = 0; part < parts.size(); ++part) + for (size_t at = 0; at < widths[part]; ++at) { + const char digit = head[started.size() + places[part] + at]; + if (digit < '0' || digit > '9') return std::nullopt; + parts[part] = parts[part] * 10 + (digit - '0'); + } + std::tm time{}; + time.tm_year = parts[0] - 1900; + time.tm_mon = parts[1] - 1; + time.tm_mday = parts[2]; + time.tm_hour = parts[3]; + time.tm_min = parts[4]; + time.tm_sec = parts[5]; + const std::int64_t began = _mkgmtime64(&time); + return began < 0 ? std::nullopt : std::optional(began); +} + +// The unix time in a dump's name, which the framework writes as `psx_freeze_dump__.json`. +std::optional dump_time(const std::wstring& name) { + constexpr std::wstring_view ending = L".json"; + if (!name.starts_with(freeze_dump) || !name.ends_with(ending)) return std::nullopt; + const size_t end = name.size() - ending.size(), under = name.rfind(L'_', end); + if (under < freeze_dump.size() || end - under < 2 || end - under > 13) return std::nullopt; + std::int64_t seconds = 0; + for (size_t at = under + 1; at < end; ++at) { + if (name[at] < L'0' || name[at] > L'9') return std::nullopt; + seconds = seconds * 10 + (name[at] - L'0'); + } + return seconds; +} + +// The newest dumps made since the last run began: an older run's would be read as this one's. +std::vector freeze_dumps(const fs::path& root, std::int64_t began) { + std::vector> found; + std::error_code failed; + for (fs::directory_iterator entry(root, failed), end; !failed && entry != end; entry.increment(failed)) { + const fs::path name = entry->path().filename(); + const std::optional made = dump_time(name.native()); + if (made && *made >= began && plain_file(entry->path())) found.emplace_back(*made, name); + } + std::ranges::sort(found, std::greater{}); + if (found.size() > most_freeze_dumps) found.resize(most_freeze_dumps); + std::vector names; + for (auto& dump : found) names.push_back(std::move(dump.second)); + return names; +} + std::string windows_value(const wchar_t* name) { std::array value{}; DWORD bytes = static_cast(sizeof(wchar_t) * (value.size() - 1)); @@ -116,11 +203,20 @@ Report make_report(const fs::path& root, std::string_view system, std::string_vi std::error_code failed; fs::remove_all(folder, failed); // one of the same second: what tar packs is what is written here and nothing older fs::create_directories(folder, failed); - write(folder / "system.txt", without_user_names(system)); - for (const char* name : gathered) { - std::error_code missing; - if (fs::is_regular_file(root / name, missing)) write(folder / name, without_user_names(end_of(root / name))); + std::vector names(gathered.begin(), gathered.end()); + const std::optional began = run_began(root / "startup.log"); + if (began) + for (auto& dump : freeze_dumps(root, *began)) names.push_back(std::move(dump)); + std::string note(system); + if (!note.empty() && note.back() != '\n') note += "\r\n"; + note += "Report made " + utc_now() + "\r\n"; + if (!began) note += "Freeze dumps: none taken, the log does not say when its run began\r\n"; + for (const fs::path& name : names) { + const bool present = plain_file(root / name); + note += narrow(name.native()) + ": " + (present ? listed(root / name) : "none") + "\r\n"; + if (present) write(folder / name, without_user_names(end_of(root / name))); } + write(folder / "system.txt", without_user_names(note)); const fs::path zip = fs::path(folder).concat(".zip"); if (!archive || !run_tar(folder, zip)) { fs::remove(zip, failed); @@ -148,6 +244,14 @@ std::string system_note() { return note; } +std::string utc_now() { + SYSTEMTIME now{}; + GetSystemTime(&now); + return utc_text(now); +} + +std::string started_line() { return std::string(started) + utc_now() + "\r\n"; } + std::string report_stamp() { const std::time_t now = std::time(nullptr); std::tm local{}; diff --git a/src/launcher/report.h b/src/launcher/report.h index afae15e..87aaf2f 100644 --- a/src/launcher/report.h +++ b/src/launcher/report.h @@ -16,9 +16,12 @@ struct Report { /// Gathers what a bug report needs from the build folder `root` into `reports\disruptor-report-`. /// -/// Taken are the game's log of the last two runs, the mouse log and the settings, each with user names cut out, and -/// `system` as `system.txt`. The disc image, the BIOS and saves are never taken. With `archive` the folder becomes a -/// ZIP of the same name when Windows' own tar can make one. +/// Taken are the game's log of the last two runs, the mouse log, the settings, the framework's notes of how the last +/// run ended and of its freezes, each with user names cut out, and `system` as `system.txt` with a line for every +/// file: its size and when it was written, or that there is none. A freeze dump is taken when its name says it was +/// made since the last run began, as the log's first line tells, and the newest two of those. A link is never +/// followed. The disc image, the BIOS and saves are never taken. With `archive` the folder becomes a ZIP of the +/// same name when Windows' own tar can make one. /// /// Throws std::runtime_error when the folder cannot be written. Report make_report(const std::filesystem::path& root, std::string_view system, std::string_view stamp, bool archive = true); @@ -26,6 +29,12 @@ Report make_report(const std::filesystem::path& root, std::string_view system, s /// The build's version, the Windows version, the display and the graphics adapters, a line each. std::string system_note(); +/// Now as `YYYY-MM-DD HH:MM:SS UTC`. +std::string utc_now(); + +/// The line the launcher heads the game's log with: when the run began. A report tells the run's freeze dumps by it. +std::string started_line(); + /// The local time as `YYYYMMDD-HHMMSS`, for a report's name. std::string report_stamp(); diff --git a/src/launcher/startup_log.cpp b/src/launcher/startup_log.cpp index 9ff3956..5cdfd7d 100644 --- a/src/launcher/startup_log.cpp +++ b/src/launcher/startup_log.cpp @@ -3,6 +3,8 @@ #include "launcher_settings.h" #include +#include +#include #include #include @@ -38,4 +40,16 @@ std::wstring log_tail(const fs::path& log, size_t lines) { return wide(joined); } +void note_exit(const fs::path& log, unsigned long code, unsigned long long seconds) { + bool mid_line = false; + if (std::ifstream file(log, std::ios::binary | std::ios::ate); file && file.tellg() > 0) { + file.seekg(-1, std::ios::end); + mid_line = file.get() != '\n'; + } + std::array line{}; + std::snprintf(line.data(), line.size(), "%slauncher: the game ended with code 0x%08lX after %llu s\r\n", + mid_line ? "\r\n" : "", code, seconds); + std::ofstream(log, std::ios::binary | std::ios::app) << line.data(); +} + } // namespace disruptor::launcher diff --git a/src/launcher/startup_log.h b/src/launcher/startup_log.h index edccf3e..8751970 100644 --- a/src/launcher/startup_log.h +++ b/src/launcher/startup_log.h @@ -9,4 +9,7 @@ namespace disruptor::launcher { // The last lines of the game's startup.log, empty when there is none. std::wstring log_tail(const std::filesystem::path& log, size_t lines); +// Adds the launcher's line for a game that ended to its log: the exit code and how long it ran. +void note_exit(const std::filesystem::path& log, unsigned long code, unsigned long long seconds); + } // namespace disruptor::launcher diff --git a/tests/contracts/test_dev_menu_contract.py b/tests/contracts/test_dev_menu_contract.py index a6d0471..a9ac360 100644 --- a/tests/contracts/test_dev_menu_contract.py +++ b/tests/contracts/test_dev_menu_contract.py @@ -765,7 +765,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: staged_keys = set(re.findall(r'\bmark\("([\w.]+)"', menu)) | set( re.findall(r'g_preferences\.changed\.insert\(\s*find_option\("([\w.]+)"\)\)', menu)) applied_keys = set(re.findall(r'\bchanged\("([\w.]+)"\)', function_body(menu, "apply_pending_preferences"))) -require(len(shared_keys) == 27 and staged_keys == shared_keys and applied_keys == shared_keys and +require(len(shared_keys) == 28 and staged_keys == shared_keys and applied_keys == shared_keys and set(re.findall(r'\bfind_option\("([\w.]+)"', menu)) <= shared_keys, "the menu must stage every shared option, apply every one it staged and name no key the table lacks") require('void mark_vsync(int value) { mark("video.vsync", value + 1); }\n' in menu and @@ -839,7 +839,7 @@ def host_ui_gl_restore_helper(source: str) -> tuple[str, str, str, str]: re.findall(r'find_option\("([\w.]+)"\)->label', menu)) present = read("src/game/frame/present_rate.cpp") require(HELPERS in menu and "bool option_list(const char *key, int *index, size_t skipped = 0);\n" in menu and - len(shared_labels) == 27 and drawn_keys == shared_keys and + len(shared_labels) == 28 and drawn_keys == shared_keys and not [text for text in [*shared_labels, *shared_choices] if f'"{text}"' in menu + present] and " static constexpr int kScaleCount = 8;\n" in menu and '"1x (native)", "2x", "3x", "4x", "5x", "6x", "7x", "8x"' in options and @@ -937,7 +937,7 @@ def greyed_by_own_rule(source: str, key: str) -> bool: r'\b(?:toggle<[^>]*>|choice|slider)\(\s*"[\w.]+",\s*"[^"]+",\s*-?\d+,\s*((?:"(?:[^"\\]|\\.)*"\s*)+)', options) ] written = "".join(re.findall(LITERAL, menu + present)) -require(HINT in read("src/game/ui/hint.h") and len(shared_hints) == 27 and +require(HINT in read("src/game/ui/hint.h") and len(shared_hints) == 28 and all(20 <= len(text) <= 240 for text in shared_hints) and not [text for text in shared_hints if text[:32] in written] and "Needs mouse aim and exact geometry." in shared_hints[ diff --git a/tests/contracts/test_diagnostics_contract.py b/tests/contracts/test_diagnostics_contract.py new file mode 100644 index 0000000..eaa2a42 --- /dev/null +++ b/tests/contracts/test_diagnostics_contract.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Source contract for the detailed log: the setting, where it is read, the driver's messages, the launcher's page and report.""" + +from pathlib import Path + +from menu_sources import menu_text + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +cmake = read("CMakeLists.txt") +renderer = read("psxrecomp/runtime/src/gpu_gl_renderer.c") +runtime = read("psxrecomp/runtime/src/main.cpp") +module = read("src/game/session/diagnostics.cpp") +host = read("src/game/host.cpp") +menu = menu_text(ROOT) +debug_tab = read("src/game/ui/menu/tab_debug.cpp") +fields = read("src/game/settings/game_user_settings.h") +options = read("src/game/settings/options.cpp") +saved = read("src/game/settings/saved.cpp") +pages = read("src/launcher/launcher_settings.cpp") +page_kinds = read("src/launcher/launcher_settings.h") +window = read("src/launcher/main.cpp") +report = read("src/launcher/report.cpp") +log = read("src/launcher/startup_log.cpp") +playing = read("docs/PLAYING.md") + +require( + " bool has_detailed_logging = false; bool detailed_logging = false;\n" in fields + and " toggle<&Settings::has_detailed_logging, &Settings::detailed_logging>(\n" + ' "disruptor.detailed_logging", "Detailed logging", 0,\n' in options + and ' if (d.contains("detailed_logging")) try_get([&]{\n' + ' s.detailed_logging = toml::find(d, "detailed_logging");\n' + " s.has_detailed_logging = true;\n" in saved + and ' f << "detailed_logging = " << (s.detailed_logging ? "true" : "false") << "\\n";\n' in saved + and "s.has_diagnostic_view || s.has_detailed_logging ||\n" in saved, + "the detailed log is a saved setting that starts off and needs nothing", +) +require( + "enum class Page { display, enhancements, controls, audio, experimental, troubleshooting };\n" in page_kinds + and 'L"Experimental", L"Troubleshooting"};\n' in pages + and ' {"disruptor.detailed_logging", Page::troubleshooting},\n' in pages + and pages.count("Page::troubleshooting") == 1 + and ' L"Switch on, play until the problem shows, close the game, then press Report a problem.",\n};\n' in window + and " SetWindowTextW(page_note, page_notes[std::clamp(page, 0, static_cast(std::size(page_notes)) - 1)]);\n" in window + and window.count(' L"",\n') == 2, + "the launcher's sixth page is Troubleshooting: the switch alone, with a note that says what to do with it", +) +require( + " bool detailed = disruptor_diagnostics_detailed() != 0;\n" + ' if (option_toggle("disruptor.detailed_logging", &detailed)) {\n' + " disruptor_diagnostics_set_detailed(detailed ? 1 : 0);\n" + " mark_detailed_logging(detailed);\n" + " }\n" + ' if (detailed) ImGui::TextDisabled("Driver messages: %s.", driver_words[disruptor_diagnostics_driver_state()]);\n' in debug_tab + and 'constexpr const char *driver_words[] = {"off", "on", "from the next launch", "not offered by this driver"};\n' in debug_tab + and "enum { DISRUPTOR_DRIVER_OFF = 0, DISRUPTOR_DRIVER_ON = 1, DISRUPTOR_DRIVER_NEXT_LAUNCH = 2, DISRUPTOR_DRIVER_REFUSED = 3 };\n" + in read("src/game/session/diagnostics.h") + and 'void mark_detailed_logging(bool value) { mark("disruptor.detailed_logging", value); }\n' in menu + and ' if (changed("disruptor.detailed_logging"))\n disruptor_diagnostics_set_detailed(pending.detailed_logging ? 1 : 0);\n' in menu, + "the Debug tab switches the detailed log at once, stages it for saving and says where the driver's part stands, a word a state", +) +require( + "bool psx_game_settings_loaded(const PSXRecompV4::UserSettings &settings) {\n" + " disruptor_diagnostics_configure(settings.has_detailed_logging && settings.detailed_logging);\n" in host + and runtime.index(" if (psx_game_settings_loaded(us)) fast_boot = true;\n") < runtime.index(" configure_core_gl_context_attributes();\n") + and " SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);\n" + " SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, gl_renderer_debug_messages() ? SDL_GL_CONTEXT_DEBUG_FLAG : 0);\n}\n" in runtime + and ' const char* forced = std::getenv("PSX_DISRUPTOR_DETAILED_LOGGING");\n' + " g_session.detailed = forced && forced[0] ? forced[0] != '0' : saved != 0;\n" + " g_session.asked_at_start = g_session.detailed;\n" + " gl_renderer_set_debug_messages(g_session.detailed ? 1 : 0);\n" in module, + "the setting is read before the game's window is made, so that its contexts are made for the driver's messages, " + "and an environment variable replaces it for a run", +) +require( + ' fprintf(stdout, "psxrecomp: OpenGL context created (%s, %s)\\n", ver ? ver : "?", card ? card : "?");\n debug_install(0);\n' + in renderer + and " s_interp_ctx = SDL_GL_CreateContext(s_win);\n" + " if (!s_interp_ctx && s_debug_asked) { /* as for the main context: a plain one, shared all the same */\n" + " SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);\n s_interp_ctx = SDL_GL_CreateContext(s_win);\n }\n" + " if (s_interp_ctx) debug_install(1);\n" in renderer + and renderer.index(" SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 1);\n") + < renderer.index(" s_interp_ctx = SDL_GL_CreateContext(s_win);\n if (!s_interp_ctx && s_debug_asked) {") + < renderer.index(" SDL_GL_SetAttribute(SDL_GL_SHARE_WITH_CURRENT_CONTEXT, 0);\n") + and " s_ctx = SDL_GL_CreateContext(win);\n if (!s_ctx && s_debug_asked) { /* a driver with no debug context still has a plain one */\n" + " s_debug_asked = 0;\n SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);\n s_ctx = SDL_GL_CreateContext(win);\n }\n" in renderer + and " if (!s_debug_asked) return;\n" in renderer + and ' if (major * 10 + minor < 43 && !SDL_GL_ExtensionSupported("GL_KHR_debug")) return;\n' in renderer + and " if (!callback || !control) return;\n glEnable(0x92E0 /* GL_DEBUG_OUTPUT */);\n" + " control(GL_DONT_CARE, GL_DONT_CARE, 0x826B /* GL_DEBUG_SEVERITY_NOTIFICATION */, 0, NULL, GL_FALSE);\n" + " control(GL_DONT_CARE, GL_DONT_CARE, 0x9148 /* GL_DEBUG_SEVERITY_LOW */, 0, NULL, GL_FALSE);\n" + " callback(debug_message, presenter ? &s_presenter_tag : NULL);\n s_debug_active = 1;\n" in renderer + and " SDL_AtomicLock(&s_debug_lock);\n if (s_debug_count < DEBUG_QUEUE) {\n" in renderer + and " } else {\n ++s_debug_lost;\n }\n SDL_AtomicUnlock(&s_debug_lock);\n}\n" in renderer + and " int taken = 0;\n SDL_AtomicLock(&s_debug_lock);\n *lost = s_debug_lost;\n s_debug_lost = 0;\n if (s_debug_count > 0) {\n" in renderer + and " taken = 1;\n }\n SDL_AtomicUnlock(&s_debug_lock);\n return taken;\n}\n" in renderer + and " memmove(&s_debug_queue[0], &s_debug_queue[1], (size_t)(--s_debug_count) * sizeof s_debug_queue[0]);\n" in renderer, + "each of the two contexts hands the driver's medium and high messages to a short queue under a lock, where the driver " + "offers them: a full queue counts what it loses, the game takes the oldest first, and a driver that makes no context " + "for them is asked for a plain one, the presenter's shared like the first", +) +require( + ' std::printf("disruptor: first frame %.1f s after the start\\n", seconds);\n' in module + and " if (!session.detailed) {\n session.announced = false;\n return;\n }\n" in module + and 'void say(const std::string& text) { std::printf("disruptor: +%.1fs %s\\n", g_session.now, text.c_str()); }\n' in module + and "void disruptor_diagnostics_note(const char *format, ...) {\n if (!g_session.detailed) return;\n" in module + and " for (int taken = 0; taken < driver_messages_a_frame; ++taken) {\n" in module + and "constexpr int driver_messages_a_frame = 16;" in module + and ' std::printf("disruptor: settings at start: the settings file is invalid and was not applied\\n");\n return;\n' in menu + and " for (const Option *option : g_preferences.changed)\n" + ' disruptor_diagnostics_note("setting %s = %d", option->key, option->get(g_preferences.pending));\n' + " g_preferences.clean();\n" in menu + and ' std::printf("disruptor: settings at start: %s\\n", disruptor::settings::describe(settings).c_str());\n' in menu, + "every session's log says its settings, or that their file is invalid, and when its first frame came, and the detailed " + "one times its lines, says each setting the menu saved and takes no more driver messages a frame than the queue holds", +) +require( + 'constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml",\n' + ' "psx_last_run_report.json", "psx_freeze_heartbeat.json"};\n' in report + and " if (made && *made >= began && plain_file(entry->path())) found.emplace_back(*made, name);\n" in report + and " return fs::is_regular_file(fs::symlink_status(file, missing));\n" in report + and ' const std::optional began = run_began(root / "startup.log");\n if (began)\n' in report + and 'std::string started_line() { return std::string(started) + utc_now() + "\\r\\n"; }\n' in report + and " std::string heading = started_line();\n" in window + and " if (found.size() > most_freeze_dumps) found.resize(most_freeze_dumps);\n" in report + and ' note += narrow(name.native()) + ": " + (present ? listed(root / name) : "none") + "\\r\\n";\n' in report + and ' write(folder / "system.txt", without_user_names(note));\n' in report + and " game = start_game(root, verified);\n game_started = GetTickCount64();\n" in window + and ' note_exit(app->root / "startup.log", code, (GetTickCount64() - app->game_started) / 1000);\n' in window + and '"%slauncher: the game ended with code 0x%08lX after %llu s\\r\\n"' in log, + "a report takes how the last run ended and lists every file with its time, and the launcher heads the log with when " + "the game started and ends it with how it ended and after how long: a freeze dump is taken when its name says it was " + "made since that start, and a link is never followed", +) +require( + "switch on **Detailed logging** on the launcher's **Troubleshooting** page" in " ".join(playing.split()) + and "PSX_DISRUPTOR_DETAILED_LOGGING" not in playing, + "the guide sends a player to the switch before the report", +) +require( + " disruptor_contract(diagnostics)\n" in cmake + and ' "${CMAKE_CURRENT_SOURCE_DIR}/src/game/session/diagnostics.cpp"\n' in cmake + and " add_test(NAME disruptor_diagnostics_forced COMMAND disruptor-diagnostics-test --forced)\n" + ' set_tests_properties(disruptor_diagnostics_forced PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_DETAILED_LOGGING=1")\n' in cmake + and " add_test(NAME disruptor_diagnostics_refused COMMAND disruptor-diagnostics-test --refused)\n" + ' set_tests_properties(disruptor_diagnostics_refused PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_DETAILED_LOGGING=0")\n' in cmake, + "this contract and the module's three test runs must remain registered", +) + +print("Disruptor diagnostics source contract: PASS") diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index e753db1..4e4af0d 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -53,7 +53,7 @@ def plain_values(text: str) -> dict[str, str]: rows = re.findall(ROW, options) keys = [key for _, key, _, _ in rows] initial = {key: int(value) for _, key, _, value in rows} -require(len(rows) == 27 and len(set(keys)) == 27, "the shared table must hold its 27 options, each key once") +require(len(rows) == 28 and len(set(keys)) == 28, "the shared table must hold its 28 options, each key once") placed = re.findall(r'^ \{"([\w.]+)", Page::\w+\},$', table, re.MULTILINE) require( sorted(placed) == sorted(keys) @@ -211,10 +211,10 @@ def plain_values(text: str) -> dict[str, str]: report = read("src/launcher/report.cpp") playing = read("docs/PLAYING.md") require( - 'constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml"};\n' + 'constexpr std::array gathered = {"startup.log", "startup.previous.log", "disruptor-mouse-aim.log", "settings.toml",\n' in report - and ' if (fs::is_regular_file(root / name, missing)) write(folder / name, without_user_names(end_of(root / name)));\n' in report - and ' write(folder / "system.txt", without_user_names(system));\n' in report + and ' if (present) write(folder / name, without_user_names(end_of(root / name)));\n' in report + and ' write(folder / "system.txt", without_user_names(note));\n' in report and " if (!archive || !run_tar(folder, zip)) {\n fs::remove(zip, failed);\n return {folder, false};\n }\n" " fs::remove_all(folder, failed);\n return {zip, true};\n" in report and ' std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\\r\\n";\n' in report, diff --git a/tests/support/stubs/psx_sdl.h b/tests/support/stubs/psx_sdl.h index aa06125..2704cad 100644 --- a/tests/support/stubs/psx_sdl.h +++ b/tests/support/stubs/psx_sdl.h @@ -6,6 +6,7 @@ struct SDL_Window {}; using Uint8 = std::uint8_t; using SDL_WindowFlags = std::uint64_t; +using SDL_JoystickID = std::uint32_t; constexpr int SDL_SCANCODE_ESCAPE = 41; constexpr int SDL_SCANCODE_A = 4; @@ -30,6 +31,7 @@ constexpr std::uint32_t SDL_BUTTON_RMASK = 0x00000004u; constexpr std::uint32_t SDL_BUTTON_X1MASK = 0x00000008u; constexpr std::uint32_t SDL_BUTTON_X2MASK = 0x00000010u; constexpr SDL_WindowFlags SDL_WINDOW_INPUT_FOCUS = 0x00000200u; +constexpr SDL_WindowFlags SDL_WINDOW_MINIMIZED = 0x00000040u; bool SDL_SetWindowRelativeMouseMode(SDL_Window *window, bool enabled); std::uint32_t SDL_GetMouseState(float *x, float *y); @@ -38,3 +40,6 @@ const Uint8 *SDL_GetKeyboardState(int *count); SDL_WindowFlags SDL_GetWindowFlags(SDL_Window *window); bool SDL_SetWindowTitle(SDL_Window *window, const char *title); const char *SDL_GetError(); +int SDL_NumJoysticks(); +SDL_JoystickID SDL_JoystickGetDeviceInstanceID(int index); +const char *SDL_GetJoystickNameForID(SDL_JoystickID device); diff --git a/tests/unit/disruptor_diagnostics_test.cpp b/tests/unit/disruptor_diagnostics_test.cpp new file mode 100644 index 0000000..429296e --- /dev/null +++ b/tests/unit/disruptor_diagnostics_test.cpp @@ -0,0 +1,282 @@ +#include "game/session/diagnostics.h" + +#include "game/frame/frame_rate.h" +#include "psx_sdl.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace disruptor::diagnostics; + +namespace { + +struct Message { + std::string text; + unsigned id; + int severity, context; +}; + +SDL_Window g_window; +void (*g_hook)(void) = nullptr; +SDL_WindowFlags g_flags = SDL_WINDOW_INPUT_FOCUS; +std::vector g_devices; +std::deque g_messages; +unsigned g_lost = 0; +bool g_endless = false; +int g_takes = 0; +int g_asked = -1, g_active = 0, g_flat = 0, g_menu = 0, g_failures = 0; +std::filesystem::path g_output; +std::streamoff g_read = 0; + +void expect(bool condition, const char *message) { + if (condition) return; + std::cerr << "FAIL: " << message << "\n"; + ++g_failures; +} + +/* What the module printed since the last call, a line an entry. */ +std::vector printed() { + std::fflush(stdout); + std::ifstream file(g_output, std::ios::binary); + file.seekg(g_read); + std::vector lines; + for (std::string line; std::getline(file, line);) { + g_read += static_cast(line.size()) + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(line); + } + return lines; +} + +bool said(const std::vector &lines, std::initializer_list wanted) { + if (lines.size() != wanted.size()) { + for (const auto &line : lines) std::cerr << " printed: " << line << "\n"; + return false; + } + size_t at = 0; + for (const char *line : wanted) { + if (lines[at++] == line) continue; + std::cerr << " printed: " << lines[at - 1] << "\n wanted: " << line << "\n"; + return false; + } + return true; +} + +} // namespace + +extern "C" { + +SDL_Window *sdl_window = &g_window; + +void mod_register_frame_hook(void (*hook)(void)) { g_hook = hook; } +void gl_renderer_set_debug_messages(int enabled) { g_asked = enabled; } +int gl_renderer_debug_messages_active(void) { return g_active; } +int gl_renderer_debug_message_take(char *text, int size, unsigned *id, int *severity, int *context, unsigned *lost) { + *lost = g_lost; + g_lost = 0; + ++g_takes; + if (g_endless) g_messages.push_back({"without end", 77, 1, 0}); + if (g_messages.empty()) return 0; + std::snprintf(text, static_cast(size), "%s", g_messages.front().text.c_str()); + *id = g_messages.front().id; + *severity = g_messages.front().severity; + *context = g_messages.front().context; + g_messages.pop_front(); + return 1; +} +int gpu_ws_displayed_flat(void) { return g_flat; } +int gpu_ws_displayed_menu(void) { return g_menu; } +int disruptor_frame_rate_last_window(DisruptorFrameRateWindow *out) { + *out = DisruptorFrameRateWindow{30, 60, 2, 0, 0}; + return 1; +} + +} // extern "C" + +SDL_WindowFlags SDL_GetWindowFlags(SDL_Window *) { return g_flags; } +int SDL_NumJoysticks() { return static_cast(g_devices.size()); } +SDL_JoystickID SDL_JoystickGetDeviceInstanceID(int index) { return static_cast(index + 1); } +const char *SDL_GetJoystickNameForID(SDL_JoystickID device) { return g_devices[device - 1].c_str(); } + +namespace { + +void test_the_lines() { + Stretch stretch; + stretch.frame(0.050); + stretch.frame(0.016); + expect(stretch.frames == 2 && stretch.waited > 0.0659 && stretch.waited < 0.0661 && stretch.longest == 0.050, + "a stretch counts its frames, sums their waits and keeps the longest"); + + expect(pulse_line(Stretch{600, 10.0, 0.0182}, Moment{30, 60, 0, "world", true, false, 0, 0}) == + "pulse: 600 frames in 10.0 s, 16.7 ms a frame, longest 18.2 ms, game 30 frames in 60 vblanks with 0 late, world, focused", + "a pulse says the frames, their mean and longest wait, the game's own window and the screen"); + expect(pulse_line(Stretch{}, Moment{0, 0, 3, "menu", false, true, 4, 2}) == + "pulse: 0 frames in 0.0 s, 0.0 ms a frame, longest 0.0 ms, game 0 frames in 0 vblanks with 3 late, menu, not focused, " + "minimized, driver messages repeated 4, driver messages lost 2", + "and a window out of focus or minimized, and driver messages left out, a stretch of no frames dividing by nothing"); + + const auto changes = device_changes({"A", "B", "B"}, {"B", "C"}); + expect(changes == std::vector{"controller removed: A", "controller removed: B", "controller connected: C"}, + "a device that left and one that came are a line each, two of one name counted apart"); + expect(device_changes({"A", "B"}, {"A", "B"}).empty() && device_changes({}, {}).empty(), "no change is no line"); + + DriverMessages driver; + expect(driver.line(7, 2, 0, "shader failed\r\n ") == "driver (main context, high, id 7): shader failed", + "a driver message names its context, its weight and its id, the line's end trimmed"); + expect(driver.line(7, 1, 1, "slow path") == "driver (presenter context, medium, id 7): slow path", "the presenter's context and a medium one"); + expect(!driver.line(7, 1, 0, "third").empty() && driver.line(7, 1, 0, "fourth").empty() && driver.line(7, 1, 0, "fifth").empty(), + "a kind is printed three times and no more"); + expect(driver.take_repeated() == 2 && driver.take_repeated() == 0, "the ones left out are counted, and the count is handed over once"); + expect(!driver.line(8, 2, 0, "another kind").empty(), "another kind is printed still"); + for (uint32_t id = 100; id < 162; ++id) (void)driver.line(id, 1, 0, "kind"); + expect(driver.take_repeated() == 0 && driver.line(900, 2, 0, "one kind too many").empty() && driver.take_repeated() == 1 && + !driver.line(8, 2, 0, "a known kind").empty(), + "past sixty-four kinds a new kind is only counted, a known one still printed"); +} + +void test_a_session() { + expect(g_hook != nullptr, "the module registers a frame hook"); + expect(g_asked == -1 && disruptor_diagnostics_detailed() == 0 && disruptor_diagnostics_driver_state() == DISRUPTOR_DRIVER_OFF, + "with nothing asked the detailed log is off and the driver is not asked for its messages"); + (void)printed(); + + g_devices = {"Pad B", "Pad A"}; + g_messages.push_back({"ignored while off", 1, 2, 0}); + frame_at(2.5); + expect(said(printed(), {"disruptor: first frame 2.5 s after the start"}), "every session says when its first frame came, and no more"); + frame_at(2.6); + disruptor_diagnostics_note("setting %s = %d", "video.vsync", 0); + expect(printed().empty() && g_messages.size() == 1, "with the detailed log off a frame and a note print nothing and take nothing"); + g_messages.clear(); + + disruptor_diagnostics_configure(1); + expect(g_asked == 1 && disruptor_diagnostics_detailed() == 1, "the saved setting switches it on and asks the driver for its messages"); + expect(disruptor_diagnostics_driver_state() == DISRUPTOR_DRIVER_REFUSED, "asked at the start and not given, the driver refused"); + g_active = 1; + expect(disruptor_diagnostics_driver_state() == DISRUPTOR_DRIVER_ON, "and given, they are on"); + + frame_at(3.0); + expect(said(printed(), {"disruptor: +3.0s detailed logging on, driver messages on", "disruptor: +3.0s controller connected: Pad A", + "disruptor: +3.0s controller connected: Pad B"}), + "the detailed log opens with what it holds and the controllers present"); + + g_messages.push_back({"shader failed\n", 5, 2, 0}); + g_lost = 2; + frame_at(3.1); + expect(said(printed(), {"disruptor: +3.1s driver (main context, high, id 5): shader failed"}), "a driver message is printed on the frame after it"); + + g_devices = {"Pad B"}; + frame_at(3.5); + expect(printed().empty(), "controllers are looked at once a second"); + frame_at(4.0); + expect(said(printed(), {"disruptor: +4.0s controller removed: Pad A"}), "and a removed one is said then"); + + disruptor_diagnostics_note("setting %s = %d", "video.vsync", 1); + expect(said(printed(), {"disruptor: +4.0s setting video.vsync = 1"}), "a note carries the time of the last frame"); + + for (int count = 0; count < 4; ++count) g_messages.push_back({"slow path", 9, 1, 1}); + frame_at(4.5); + expect(said(printed(), {"disruptor: +4.5s driver (presenter context, medium, id 9): slow path", + "disruptor: +4.5s driver (presenter context, medium, id 9): slow path", + "disruptor: +4.5s driver (presenter context, medium, id 9): slow path"}), + "a kind said four times is printed three times"); + + frame_at(12.9); + expect(printed().empty(), "no pulse before ten seconds are over"); + frame_at(13.0); + expect(said(printed(), {"disruptor: +13.0s pulse: 6 frames in 10.0 s, 1666.7 ms a frame, longest 8400.0 ms, game 30 frames in 60 vblanks " + "with 2 late, world, focused, driver messages repeated 1, driver messages lost 2"}), + "the pulse covers the frames since the log opened, with the messages left out and the ones the queue lost"); + + g_flat = 1; + g_flags = SDL_WINDOW_MINIMIZED; + frame_at(23.0); + expect(said(printed(), {"disruptor: +23.0s pulse: 1 frames in 10.0 s, 10000.0 ms a frame, longest 10000.0 ms, game 30 frames in 60 vblanks " + "with 2 late, flat screen, not focused, minimized"}), + "the next pulse starts anew: a flat screen, a minimized window, nothing lost"); + g_menu = 1; + g_flags = SDL_WINDOW_INPUT_FOCUS; + frame_at(33.0); + expect(said(printed(), {"disruptor: +33.0s pulse: 1 frames in 10.0 s, 10000.0 ms a frame, longest 10000.0 ms, game 30 frames in 60 vblanks " + "with 2 late, menu, focused"}), + "the front end's menu is told from a flat screen"); + frame_at(40.0); + expect(printed().empty(), "a frame between pulses prints nothing"); + + disruptor_diagnostics_set_detailed(0); + g_messages.push_back({"while off", 6, 2, 0}); + frame_at(50.0); + disruptor_diagnostics_note("unseen"); + expect(printed().empty() && disruptor_diagnostics_driver_state() == DISRUPTOR_DRIVER_OFF, "switched off in the menu it stops at once"); + g_messages.clear(); + + disruptor_diagnostics_configure(0); + expect(g_asked == 0, "a start with the setting off does not ask the driver"); + g_active = 0; + g_devices.clear(); + disruptor_diagnostics_set_detailed(1); + expect(disruptor_diagnostics_driver_state() == DISRUPTOR_DRIVER_NEXT_LAUNCH, "switched on in the menu, the driver's part waits for a launch"); + frame_at(51.0); + expect(said(printed(), {"disruptor: +51.0s detailed logging on, driver messages from the next launch", "disruptor: +51.0s controllers: none"}), + "and the log says so when it opens, and that no controller is there"); + frame_at(52.0); + expect(printed().empty(), "that no controller is there is said once"); + g_devices = {""}; + frame_at(60.9); + expect(said(printed(), {"disruptor: +60.9s controller connected: unnamed device"}), + "a log opened again counts its ten seconds from there, and a device with no name is still said"); + frame_at(61.0); + expect(said(printed(), {"disruptor: +61.0s pulse: 3 frames in 10.0 s, 3333.3 ms a frame, longest 8900.0 ms, game 30 frames in 60 vblanks " + "with 2 late, menu, focused"}), + "and its pulse leaves out the frames before it"); + + disruptor_diagnostics_set_detailed(0); + frame_at(61.1); + disruptor_diagnostics_set_detailed(1); + frame_at(61.2); + expect(said(printed(), {"disruptor: +61.2s detailed logging on, driver messages from the next launch", + "disruptor: +61.2s controller connected: unnamed device"}), + "a log opened again within a second looks at the controllers at once"); + + g_endless = true; + g_takes = 0; + frame_at(61.3); + g_endless = false; + g_messages.clear(); + expect(g_takes == 16 && printed().size() == 3, "a driver that never stops is asked sixteen times a frame and no more"); +} + +} // namespace + +int main(int argc, char **argv) { + const std::string mode = argc > 1 ? argv[1] : ""; + if (mode == "--forced") { + expect(g_asked == 1 && disruptor_diagnostics_detailed() == 1, "the environment switches the detailed log on before any setting is read"); + disruptor_diagnostics_configure(0); + expect(g_asked == 1 && disruptor_diagnostics_detailed() == 1, "and a saved setting that says off does not take it back"); + } else if (mode == "--refused") { + expect(g_asked == 0 && disruptor_diagnostics_detailed() == 0, "the environment's zero leaves the detailed log off"); + disruptor_diagnostics_configure(1); + expect(g_asked == 0 && disruptor_diagnostics_detailed() == 0, "against a saved setting that says on"); + } else { + g_output = std::filesystem::temp_directory_path() / "disruptor-diagnostics-test.txt"; + if (!std::freopen(g_output.string().c_str(), "w", stdout)) { + std::cerr << "FAIL: the test cannot take the standard output\n"; + return 1; + } + test_the_lines(); + test_a_session(); + std::fclose(stdout); + std::error_code left; + std::filesystem::remove(g_output, left); + } + if (g_failures) return 1; + std::cerr << "disruptor diagnostics: PASS\n"; + return 0; +} diff --git a/tests/unit/launcher_report_test.cpp b/tests/unit/launcher_report_test.cpp index 7e45797..7cffcdf 100644 --- a/tests/unit/launcher_report_test.cpp +++ b/tests/unit/launcher_report_test.cpp @@ -1,5 +1,8 @@ #include "launcher/report.h" +#include +#include +#include #include #include #include @@ -53,7 +56,10 @@ void test_user_names_are_cut() { void test_a_report_takes_logs_and_settings_only() { const fs::path root = fs::temp_directory_path() / "disruptor-report-test"; fs::remove_all(root); - put(root / "startup.log", R"(psxrecomp: loaded C:\Users\Alice\Games\game.toml)" "\n"); + const long long began = static_cast(std::time(nullptr)); + const std::string heading = started_line(); + const auto dump = [](long long seconds) { return "psx_freeze_dump_psx-runtime_" + std::to_string(seconds) + ".json"; }; + put(root / "startup.log", heading + R"(psxrecomp: loaded C:\Users\Alice\Games\game.toml)" "\n"); put(root / "startup.previous.log", "the run before\n"); put(root / "disruptor-mouse-aim.log", "event=ready\n"); put(root / "settings.toml", R"(language_disc = "C:\\Users\\Alice\\d.cue")" "\n"); @@ -61,25 +67,81 @@ void test_a_report_takes_logs_and_settings_only() { put(root / "saves" / "card.mcd", "save"); put(root / "disc" / "Disruptor.bin", "disc"); put(root / "DisruptorRecompiled.exe", "exe"); + put(root / "psx_last_run_report.json", R"({"reason": "seh", "module": "C:\\Users\\Alice\\x.dll"})"); + put(root / "psx_freeze_heartbeat.json", "{}"); + for (const long long made : {began - 3600, began - 1, began + 10, began + 20, began + 30}) put(root / dump(made), "{}"); + put(root / "psx_freeze_dump_private.json", "a file that only begins like a dump"); + put(root / ("psx_freeze_dump_psx-runtime_" + std::to_string(began + 40) + "x.json"), "no number"); + put(root / ("psx_freeze_dump_psx-runtime_" + std::to_string(began + 41) + ".txt"), "no JSON"); + put(root / ("psx_freeze_dump_psx-runtime_1" + std::to_string(began + 42) + "999.json"), "a number too long for a time"); + put(root / ("other_dump_psx-runtime_" + std::to_string(began + 43) + ".json"), "another name"); + put(root / ("psx_freeze_dump_" + std::to_string(began + 44) + ".json"), "no backend named"); + fs::create_directories(root / dump(began + 50)); + put(root / "elsewhere" / "private.json", "a file a link leads to"); + std::error_code no_link; + fs::create_symlink(root / "elsewhere" / "private.json", root / dump(began + 60), no_link); const Report plain = make_report(root, R"(system of C:\Users\Alice)", "20261013-010203", false); expect(!plain.archived && plain.path == root / "reports" / "disruptor-report-20261013-010203" && fs::is_directory(plain.path), "without an archive the report is a folder named by its stamp under reports"); - expect(read(plain.path / "startup.log") == R"(psxrecomp: loaded C:\Users\\Games\game.toml)" "\n", + expect(read(plain.path / "startup.log") == heading + R"(psxrecomp: loaded C:\Users\\Games\game.toml)" "\n", "the log is taken with the user's name cut out"); expect(read(plain.path / "settings.toml") == R"(language_disc = "C:\\Users\\\\d.cue")" "\n", "and so are the settings"); - expect(read(plain.path / "system.txt") == R"(system of C:\Users\)", "and the system note"); + const std::string system = read(plain.path / "system.txt"); + expect(system.rfind(R"(system of C:\Users\)" "\r\nReport made 20", 0) == 0, "and the system note, which says when the report was made"); + expect(system.find("\r\nstartup.log: " + std::to_string(49 + heading.size()) + " bytes, written 20") != std::string::npos && + system.find(" UTC\r\nstartup.previous.log: 15 bytes, written 20") != std::string::npos && + system.find(dump(began + 30) + ": 2 bytes, written 20") < system.find(dump(began + 20) + ": 2 bytes, written 20") && + system.find("Freeze dumps:") == std::string::npos, + "the note lists every file with its size and when it was written, the dumps newest first"); + expect(read(plain.path / "psx_last_run_report.json") == R"({"reason": "seh", "module": "C:\\Users\\\\x.dll"})" && + read(plain.path / "psx_freeze_heartbeat.json") == "{}", + "how the last run ended and the freeze heartbeat are taken, a user's name cut out of them too"); + expect(fs::exists(plain.path / dump(began + 30)) && fs::exists(plain.path / dump(began + 20)) && !fs::exists(plain.path / dump(began + 10)), + "of the freeze dumps made since the run began the newest two"); expect(read(plain.path / "startup.previous.log") == "the run before\n" && read(plain.path / "disruptor-mouse-aim.log") == "event=ready\n", "the run before and the mouse log are taken"); size_t files = 0; for (const auto& entry : fs::recursive_directory_iterator(plain.path)) files += entry.is_regular_file(); - expect(files == 5, "nothing else is taken: no BIOS, no save, no disc image, no executable"); + expect(files == 9, "nothing else is taken: no BIOS, no save, no disc image, no executable, no file or folder or link that only looks like a dump"); + + fs::remove(root / dump(began + 30)); + fs::remove(root / dump(began + 20)); + const Report bounded = make_report(root, "system", "bounded", false); + expect(fs::exists(bounded.path / dump(began + 10)) && !fs::exists(bounded.path / dump(began - 1)) && !fs::exists(bounded.path / dump(began - 3600)), + "a dump from before the run began is left out though there is room for it"); + + if (!no_link) { + fs::remove(root / "settings.toml"); + fs::create_symlink(root / "elsewhere" / "private.json", root / "settings.toml", no_link); + const Report linked = make_report(root, "system", "linked", false); + expect(!no_link && !fs::exists(linked.path / "settings.toml") && + read(linked.path / "system.txt").find("\r\nsettings.toml: none\r\n") != std::string::npos, + "a link where a file of the report should be is not followed"); + fs::remove(root / "settings.toml"); + put(root / "settings.toml", "muted = true\n"); + } fs::remove(root / "startup.previous.log"); fs::remove(root / "disruptor-mouse-aim.log"); - const Report sparse = make_report(root, "system", "sparse", false); - expect(fs::exists(sparse.path / "startup.log") && !fs::exists(sparse.path / "startup.previous.log") && !fs::exists(sparse.path / "disruptor-mouse-aim.log"), - "a file the build folder does not have is not made up"); + fs::remove(root / "psx_last_run_report.json"); + for (const std::string& unheaded : {std::string("a log the launcher did not head\n"), "launcher: started 2026-1x-13 04:09:07 UTC\r\n" + heading, + "LAUNCHER: STARTED " + heading.substr(18), "\n" + heading, + heading.substr(0, heading.size() - 12)}) { + put(root / "startup.log", unheaded); + const Report sparse = make_report(root, "system", "sparse", false); + expect(fs::exists(sparse.path / "startup.log") && !fs::exists(sparse.path / "startup.previous.log") && + !fs::exists(sparse.path / "disruptor-mouse-aim.log"), + "a file the build folder does not have is not made up"); + const std::string sparse_note = read(sparse.path / "system.txt"); + expect(sparse_note.rfind("system\r\nReport made 20", 0) == 0 && sparse_note.find("\r\nstartup.previous.log: none\r\n") != std::string::npos && + sparse_note.find("\r\npsx_last_run_report.json: none\r\n") != std::string::npos, + "and the note says there is none"); + expect(!fs::exists(sparse.path / dump(began + 10)) && + sparse_note.find("\r\nFreeze dumps: none taken, the log does not say when its run began\r\n") != std::string::npos, + "a log that does not begin with a whole start line tells no run: no dump is taken, and the note says why"); + } + put(root / "startup.log", heading + "the last run\n"); const Report packed = make_report(root, "system", "packed"); const fs::path folder = root / "reports" / "disruptor-report-packed"; @@ -99,6 +161,9 @@ void test_a_report_takes_logs_and_settings_only() { const std::string kept = read(cut.path / "startup.log"); expect(kept.size() < 4 * 1024 * 1024 && kept.find("secret") == std::string::npos && kept.find("filler\n") < 8, "of a long log the end is taken, from a whole line on: a line cut inside a user's name is left out"); + expect(read(cut.path / "system.txt").find("\r\nstartup.log: 4194366 bytes, written 20") != std::string::npos && + read(cut.path / "system.txt").find(" UTC, its end taken\r\n") != std::string::npos, + "and the note says so beside the log's whole size"); expect(!fs::exists(cut.path / "left-over.txt"), "and what an older report left in the folder does not ride along"); bool refused = false; @@ -116,6 +181,24 @@ void test_the_notes_have_their_shape() { const std::string stamp = report_stamp(); expect(stamp.size() == 15 && stamp[8] == '-' && stamp.find_first_not_of("0123456789-") == std::string::npos, "a stamp is a date and a time of digits"); + const auto universal = [] { + const std::time_t now = std::time(nullptr); + std::tm parts{}; + gmtime_s(&parts, &now); + std::array text{}; + std::strftime(text.data(), text.size(), "%Y-%m-%d %H:%M:%S UTC", &parts); + return std::string(text.data()); + }; + const std::string before = universal(); + const std::string made = utc_now(); + const std::string after = universal(); + expect(before <= made && made <= after, "the time of now is the universal one, whatever zone the machine is in"); + expect(made.size() == 23 && made[4] == '-' && made[10] == ' ' && made[13] == ':' && made.ends_with(" UTC") && + made.find_first_not_of("0123456789-: UTC") == std::string::npos, + "the time of now is a date, a time and UTC"); + const std::string line = started_line(); + expect(line.rfind("launcher: started 20", 0) == 0 && line.size() == 43 && line.ends_with(" UTC\r\n"), + "the line that heads a log says when the run began"); const std::string note = system_note(); expect(note.rfind("Disruptor Recompiled ", 0) == 0 && note.find("Windows: ") != std::string::npos, "the system note begins with the build's version and names the Windows version"); diff --git a/tests/unit/launcher_settings_test.cpp b/tests/unit/launcher_settings_test.cpp index 36ba231..72bfb51 100644 --- a/tests/unit/launcher_settings_test.cpp +++ b/tests/unit/launcher_settings_test.cpp @@ -124,6 +124,27 @@ void needed() { "and only when what it needs applies itself"); } +void described() { + Settings settings; + const Option& logging = *find_option("disruptor.detailed_logging"); + const Option& volume = *find_option("audio.master_volume"); + require(logging.kind == Kind::toggle && logging.initial == 0 && stored(settings, logging) == 0 && stored(settings, volume) == 100, + "an option the file does not name has a fresh installation's value, and the detailed log starts off"); + logging.set(settings, 1); + volume.set(settings, 40); + require(stored(settings, logging) == 1 && stored(settings, volume) == 40, "a named one has the file's"); + const std::string text = disruptor::settings::describe(settings); + size_t at = 0, count = 0; + for (const auto& option : options()) { + const std::string entry = std::string(count++ ? " " : "") + option.key + "=" + std::to_string(stored(settings, option)); + require(text.compare(at, entry.size(), entry) == 0, "the settings line names every option with its value, in the table's order"); + at += entry.size(); + } + require(at == text.size() && text.find(" audio.master_volume=40 ") != std::string::npos && + text.find(" disruptor.detailed_logging=1 ") != std::string::npos, + "and nothing else"); +} + void placements() { std::set placed; for (const auto& row : layout()) { @@ -132,6 +153,10 @@ void placements() { require(static_cast(row.page) < page_labels().size(), "a row is on no page"); } require(placed.size() == options().size(), "an option is on no page"); + require(page_labels().size() == 6 && std::wstring_view(page_labels()[5]) == L"Troubleshooting", "the sixth page is Troubleshooting"); + for (const auto& row : layout()) + require((std::string_view(row.key) == "disruptor.detailed_logging") == (row.page == Page::troubleshooting), + "the Troubleshooting page holds the detailed log's switch and nothing else"); require(wide("caf\xC3\xA9") == L"café" && wide("").empty(), "an option's text is read as UTF-8"); } @@ -407,6 +432,21 @@ void log(const fs::path& folder) { require(log_tail(file, 1) == L"caf�\r\n", "a log that is not UTF-8 is still shown"); write(file, std::string(20000, 'x') + "\xD0\x94\nlast\n"); require(log_tail(file, 5) == L"last\r\n", "a line cut by the read is not shown"); + write(file, "the game's last line\n"); + note_exit(file, 0, 125); + require(log_tail(file, 2) == L"the game's last line\r\nlauncher: the game ended with code 0x00000000 after 125 s\r\n", + "the launcher adds how the game ended to its log"); + std::string raw(200, '\0'); + raw.resize(static_cast(std::ifstream(file, std::ios::binary).read(raw.data(), 200).gcount())); + require(raw == "the game's last line\nlauncher: the game ended with code 0x00000000 after 125 s\r\n", + "straight after a whole line, with no empty one between"); + write(file, "cut mid-li"); + note_exit(file, 0xC0000005ul, 3); + require(log_tail(file, 2) == L"cut mid-li\r\nlauncher: the game ended with code 0xC0000005 after 3 s\r\n", + "on a line of its own where the game was cut mid-line"); + fs::remove(file); + note_exit(file, 1, 0); + require(log_tail(file, 2) == L"launcher: the game ended with code 0x00000001 after 0 s\r\n", "and in a log that was not there"); fs::remove(file); } @@ -493,6 +533,7 @@ int main() { carried(); needed(); placements(); + described(); absent(folder); one_change(folder); round_trip(folder); diff --git a/tests/unit/user_settings_persistence_test.cpp b/tests/unit/user_settings_persistence_test.cpp index 5d149c7..9d51747 100644 --- a/tests/unit/user_settings_persistence_test.cpp +++ b/tests/unit/user_settings_persistence_test.cpp @@ -112,6 +112,8 @@ int main() { written.gap_filling = false; written.has_depth_sorted_world = true; written.depth_sorted_world = true; + written.has_detailed_logging = true; + written.detailed_logging = true; written.has_diagnostic_view = true; written.diagnostic_view = 3; written.has_language = true; @@ -168,6 +170,7 @@ int main() { loaded.has_occlusion_strength && loaded.occlusion_strength == 130 && loaded.has_gap_filling && !loaded.gap_filling && loaded.has_depth_sorted_world && loaded.depth_sorted_world && + loaded.has_detailed_logging && loaded.detailed_logging && loaded.has_diagnostic_view && loaded.diagnostic_view == 3, "Disruptor preferences did not round-trip"); require(loaded.has_language && loaded.language == "en", From b71f342a222a051e6032d9b44165d4807ae5500a Mon Sep 17 00:00:00 2001 From: Solganis Date: Tue, 13 Oct 2026 05:54:43 +0300 Subject: [PATCH 3/4] fix: name Windows 11 in a report's system note --- src/launcher/report.cpp | 13 ++++++++++++- src/launcher/report.h | 4 ++++ tests/contracts/test_launcher_contract.py | 3 ++- tests/unit/launcher_report_test.cpp | 8 ++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/launcher/report.cpp b/src/launcher/report.cpp index 6477a2a..ff818d0 100644 --- a/src/launcher/report.cpp +++ b/src/launcher/report.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -226,9 +227,19 @@ Report make_report(const fs::path& root, std::string_view system, std::string_vi return {zip, true}; } +std::string windows_name(std::string_view product, std::string_view build) { + constexpr std::string_view ten = "Windows 10"; + std::string name(product); + int number = 0; + const auto read = std::from_chars(build.data(), build.data() + build.size(), number); + if (read.ec == std::errc() && number >= 22000 && name.starts_with(ten)) name.replace(ten.size() - 2, 2, "11"); + return name; +} + std::string system_note() { std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\r\n"; - note += "Windows: " + windows_value(L"ProductName") + ", version " + windows_value(L"DisplayVersion") + ", build " + + note += "Windows: " + windows_name(windows_value(L"ProductName"), windows_value(L"CurrentBuildNumber")) + ", version " + + windows_value(L"DisplayVersion") + ", build " + windows_value(L"CurrentBuildNumber") + "\r\n"; DEVMODEW mode{}; mode.dmSize = sizeof(mode); diff --git a/src/launcher/report.h b/src/launcher/report.h index 87aaf2f..4591474 100644 --- a/src/launcher/report.h +++ b/src/launcher/report.h @@ -26,6 +26,10 @@ struct Report { /// Throws std::runtime_error when the folder cannot be written. Report make_report(const std::filesystem::path& root, std::string_view system, std::string_view stamp, bool archive = true); +/// Windows' own product name with the 10 of a Windows 11 put right: the registry still says 10 there, and its builds +/// begin at 22000. +std::string windows_name(std::string_view product, std::string_view build); + /// The build's version, the Windows version, the display and the graphics adapters, a line each. std::string system_note(); diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 4e4af0d..2aad000 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -217,7 +217,8 @@ def plain_values(text: str) -> dict[str, str]: and ' write(folder / "system.txt", without_user_names(note));\n' in report and " if (!archive || !run_tar(folder, zip)) {\n fs::remove(zip, failed);\n return {folder, false};\n }\n" " fs::remove_all(folder, failed);\n return {zip, true};\n" in report - and ' std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\\r\\n";\n' in report, + and ' std::string note = "Disruptor Recompiled " DISRUPTOR_BUILD_VERSION "\\r\\n";\n' in report + and ' note += "Windows: " + windows_name(windows_value(L"ProductName"), windows_value(L"CurrentBuildNumber")) + ", version " +\n' in report, "a report takes the game's log of two runs, the mouse log and the settings, each with user names cut out, and a note " "of the system: one ZIP where Windows' tar makes it, with no folder and no half-made ZIP left beside it", ) diff --git a/tests/unit/launcher_report_test.cpp b/tests/unit/launcher_report_test.cpp index 7cffcdf..b08b82c 100644 --- a/tests/unit/launcher_report_test.cpp +++ b/tests/unit/launcher_report_test.cpp @@ -199,6 +199,14 @@ void test_the_notes_have_their_shape() { const std::string line = started_line(); expect(line.rfind("launcher: started 20", 0) == 0 && line.size() == 43 && line.ends_with(" UTC\r\n"), "the line that heads a log says when the run began"); + expect(windows_name("Windows 10 Pro", "26200") == "Windows 11 Pro" && windows_name("Windows 10 Home", "22000") == "Windows 11 Home", + "Windows 11 is named so, though its registry says 10"); + expect(windows_name("Windows 10 Pro", "21999") == "Windows 10 Pro" && windows_name("Windows 10 Pro", "19045") == "Windows 10 Pro", + "and Windows 10 stays 10"); + expect(windows_name("Windows Server 2025 Standard", "26100") == "Windows Server 2025 Standard" && + windows_name("My Windows 10", "26200") == "My Windows 10" && windows_name("Windows 10 Pro", "?") == "Windows 10 Pro" && + windows_name("", "26200").empty(), + "another name and a build that is no number are left as they are"); const std::string note = system_note(); expect(note.rfind("Disruptor Recompiled ", 0) == 0 && note.find("Windows: ") != std::string::npos, "the system note begins with the build's version and names the Windows version"); From 73e985cd3bdfc70565be1b3b05c380a8cd8286d7 Mon Sep 17 00:00:00 2001 From: Solganis Date: Tue, 13 Oct 2026 04:08:40 +0300 Subject: [PATCH 4/4] feat: hold the mouse while the game has focus, with no click to capture --- CMakeLists.txt | 5 + README.md | 5 +- docs/PLAYING.md | 6 +- src/game/input/mouse_aim.cpp | 52 +++-- tests/contracts/test_launcher_contract.py | 7 +- tests/unit/disruptor_mouse_aim_test.cpp | 237 +++++++++++++++------- 6 files changed, 212 insertions(+), 100 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ddf258f..b6a4a1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,11 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) set_tests_properties(disruptor_mouse_aim PROPERTIES ENVIRONMENT "PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=") + add_test(NAME disruptor_mouse_aim_replay + COMMAND disruptor-mouse-aim-test --replay) + set_tests_properties(disruptor_mouse_aim_replay PROPERTIES + ENVIRONMENT "PSX_DISRUPTOR_CAPSULE_REPLAY=capsule;PSX_DISRUPTOR_MOUSE_AIM=;PSX_DISRUPTOR_VERTICAL_LOOK=;PSX_DISRUPTOR_MODERN_CONTROLS=;PSX_DISRUPTOR_HIGH_PRECISION_CAMERA=") + add_test(NAME disruptor_mouse_aim_registration COMMAND disruptor-mouse-aim-test --registration) set_tests_properties(disruptor_mouse_aim_registration PROPERTIES diff --git a/README.md b/README.md index cf8d2f9..941a3af 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ Press **backquote** (`` ` ``) to open settings; **Escape** closes them. Gameplay continues while the menu is open, but game input is blocked. With modern controls enabled: **WASD** moves, **left mouse** fires, **right -mouse** uses psionics, **Space** jumps, and **E** interacts. **Middle-click** -captures the mouse; middle-click or Escape releases it. Add `-VerticalLook` to +mouse** uses psionics, **Space** jumps, and **E** interacts. The game holds +the mouse while its window has focus. Escape or a middle-click releases it, +and a left-click in the window takes it back. Add `-VerticalLook` to enable experimental vertical aim. **Alt+Enter** toggles fullscreen. **Shift+F1–F12** saves a state; diff --git a/docs/PLAYING.md b/docs/PLAYING.md index dbe21b2..2098dbd 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -57,7 +57,7 @@ folder; these backups can be removed manually once the repaired copy works. | --- | --- | | Move / strafe | W / S / A / D | | Turn | Mouse, after capture; arrow keys also work | -| Capture / release mouse | Middle-click; Escape also releases | +| Release / capture mouse | Escape or middle-click releases; a left or middle click captures again | | Fire / menu confirm | Left mouse / Enter | | Psionic attack | Right mouse / F | | Jump / menu cancel | Space | @@ -202,7 +202,9 @@ for the replay tool and its limits. - **Slow rendering:** lower resolution to 2x or 1x in Enhancements and update your graphics driver. Try turning off 60 FPS gameplay and in-between frames if they are enabled. -- **Mouse does not turn:** enter gameplay and middle-click to capture it. +- **Mouse does not turn:** the game holds the mouse while its window has + focus and mouse aim or modern controls are on. After Escape or a + middle-click, left-click in the window to capture it again. - **Other problems:** see [Reporting a problem](#reporting-a-problem). Do not attach your game image, game assets, or BIOS files. diff --git a/src/game/input/mouse_aim.cpp b/src/game/input/mouse_aim.cpp index fda5dab..4e8b4f6 100644 --- a/src/game/input/mouse_aim.cpp +++ b/src/game/input/mouse_aim.cpp @@ -85,7 +85,9 @@ struct MouseAimState { bool high_precision_camera = false; bool configured = false; bool captured = false; - bool recapture_on_focus = false; + bool given_up = false; // the player let the mouse go with Escape or a middle click: only a click takes it back + bool swallow_left = false; // the click that took the mouse is no shot: the left button waits for its release + bool left_was_down = false; bool middle_was_down = false; bool escape_was_down = false; bool invert_horizontal = false; @@ -309,6 +311,7 @@ void set_capture(bool captured, const char *reason) { if (captured == g_mouse.captured) return; if (!set_relative_mouse_mode(captured)) { set_title("ERROR enabling relative mode; see log"); + std::printf("disruptor: mouse %s failed (%s): %s\n", captured ? "capture" : "release", reason, SDL_GetError()); if (g_mouse.log) { std::fprintf(g_mouse.log, "event=relative-mode-error requested=%s error=%s\n", captured ? "capture" : "release", SDL_GetError()); @@ -323,12 +326,13 @@ void set_capture(bool captured, const char *reason) { gpu_geometry_camera_yaw_residual_set(0.0); discard_relative_motion(); log_event(reason); + std::printf("disruptor: mouse %s (%s)\n", captured ? "captured" : "released", reason); if (captured) { set_title(g_mouse.modern_controls_enabled ? "CAPTURED - LMB fire, RMB psionic; middle-click or Esc releases" : "CAPTURED - move to aim; middle-click or Esc releases"); } else { - set_title("released - middle-click in live gameplay to capture"); + set_title("released - left-click in the window to capture"); } } @@ -563,6 +567,7 @@ void mouse_aim_frame() { g_mouse.escape_was_down = keys && keys[SDL_SCANCODE_ESCAPE] != 0; g_mouse.middle_was_down = (buttons & SDL_BUTTON_MMASK) != 0; + g_mouse.left_was_down = (buttons & SDL_BUTTON_LMASK) != 0; discard_relative_motion(); gpu_geometry_camera_yaw_residual_set(0.0); return; @@ -571,7 +576,7 @@ void mouse_aim_frame() { static bool announced = false; if (!announced) { announced = true; - set_title("middle-click in live gameplay to capture"); + set_title("ready"); log_event("ready"); } @@ -579,35 +584,38 @@ void mouse_aim_frame() { const bool escape_down = keys && keys[SDL_SCANCODE_ESCAPE] != 0; const uint32_t buttons = get_mouse_buttons(); const bool middle_down = (buttons & SDL_BUTTON_MMASK) != 0; + const bool left_down = (buttons & SDL_BUTTON_LMASK) != 0; const bool focused = (SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_INPUT_FOCUS) != 0; - if (escape_down && !g_mouse.escape_was_down && - (g_mouse.captured || g_mouse.recapture_on_focus)) { - /* The player's key and click come before the focus branches: on the - * frame focus goes or comes back those would take them and the mouse - * would return uncalled. */ - g_mouse.recapture_on_focus = false; + const bool usable = g_mouse.mouse_aim_enabled || g_mouse.vertical_look_enabled || g_mouse.modern_controls_enabled; + const bool was_captured = g_mouse.captured; + /* The mouse is held whenever the window has focus, with no click asked for. The player's key and clicks come before the + * focus branches: on the frame focus goes or comes back those would take them. */ + if (escape_down && !g_mouse.escape_was_down && (g_mouse.captured || !g_mouse.given_up)) { + g_mouse.given_up = true; set_capture(false, "escape-release"); } else if (middle_down && !g_mouse.middle_was_down) { - /* A capture that waits for focus counts as held: the click gives it up. */ - const bool held = g_mouse.captured || g_mouse.recapture_on_focus; - g_mouse.recapture_on_focus = false; - set_capture(!held, held ? "middle-release" : "middle-capture"); + g_mouse.given_up = !g_mouse.given_up; + set_capture(!g_mouse.given_up && focused, g_mouse.given_up ? "middle-release" : "middle-capture"); + } else if (left_down && !g_mouse.left_was_down && g_mouse.given_up && focused) { + g_mouse.given_up = false; + set_capture(true, "click-capture"); } else if (g_mouse.captured && !focused) { set_capture(false, "focus-lost-release"); - g_mouse.recapture_on_focus = !g_mouse.captured; - } else if (g_mouse.recapture_on_focus && focused) { - /* The player's own choice stands across Alt+Tab: only a middle click - * or Escape gives the mouse up for good. */ - g_mouse.recapture_on_focus = false; - set_capture(true, "focus-regained-capture"); + } else if (!g_mouse.captured && !g_mouse.given_up && focused && usable) { + set_capture(true, "focus-capture"); + /* Relative mode refused: asking again every frame would fill the log. A click asks again. */ + if (!g_mouse.captured) g_mouse.given_up = true; } + if (g_mouse.captured && !was_captured && left_down) g_mouse.swallow_left = true; + if (!left_down) g_mouse.swallow_left = false; g_mouse.middle_was_down = middle_down; g_mouse.escape_was_down = escape_down; + g_mouse.left_was_down = left_down; - apply_modern_controls(keys, buttons); + apply_modern_controls(keys, g_mouse.swallow_left ? buttons & ~static_cast(SDL_BUTTON_LMASK) : buttons); if (g_mouse.captured && (g_mouse.mouse_aim_enabled || g_mouse.vertical_look_enabled)) { /* SDL drains both relative axes in one call. Keep this a single @@ -686,6 +694,8 @@ void mouse_aim_frame() { struct MouseAimRegistration { MouseAimRegistration() { + // A replay's turns are the recording's: its run takes the mouse only when the viewer clicks for it. + g_mouse.given_up = std::getenv("PSX_DISRUPTOR_CAPSULE_REPLAY") != nullptr; g_mouse.mouse_aim_enabled = env_enabled("PSX_DISRUPTOR_MOUSE_AIM"); g_mouse.vertical_look_enabled = @@ -895,7 +905,6 @@ extern "C" int disruptor_mouse_captured(void) { extern "C" int disruptor_mouse_set_captured(int captured) { load_configuration(); const bool next = captured != 0; - g_mouse.recapture_on_focus = false; if (next && !g_mouse.mouse_aim_enabled && !g_mouse.vertical_look_enabled && !g_mouse.modern_controls_enabled) { @@ -912,6 +921,7 @@ extern "C" int disruptor_mouse_set_captured(int captured) { g_mouse.escape_was_down = keys && keys[SDL_SCANCODE_ESCAPE] != 0; g_mouse.middle_was_down = (buttons & SDL_BUTTON_MMASK) != 0; + g_mouse.left_was_down = g_mouse.swallow_left = (buttons & SDL_BUTTON_LMASK) != 0; } return g_mouse.captured == next ? 1 : 0; } diff --git a/tests/contracts/test_launcher_contract.py b/tests/contracts/test_launcher_contract.py index 2aad000..ea3a6da 100644 --- a/tests/contracts/test_launcher_contract.py +++ b/tests/contracts/test_launcher_contract.py @@ -237,10 +237,13 @@ def plain_values(text: str) -> dict[str, str]: "makes a report at any time, an import or a running game included", ) require( - " const char *card = (const char *)glGetString(GL_RENDERER);\n" + ' std::printf("disruptor: mouse %s failed (%s): %s\\n", captured ? "capture" : "release", reason, SDL_GetError());\n' in mouse + and ' std::printf("disruptor: mouse %s (%s)\\n", captured ? "captured" : "released", reason);\n' in mouse + and " const char *card = (const char *)glGetString(GL_RENDERER);\n" ' fprintf(stdout, "psxrecomp: OpenGL context created (%s, %s)\\n", ver ? ver : "?", card ? card : "?");\n' in read("psxrecomp/runtime/src/gpu_gl_renderer.c"), - "the game's log names the graphics card beside its driver", + "the game's log says each time the mouse is taken or let go and why, a refusal with the system's reason, and names the " + "graphics card beside its driver", ) require( " report.cpp\n" in launcher_cmake diff --git a/tests/unit/disruptor_mouse_aim_test.cpp b/tests/unit/disruptor_mouse_aim_test.cpp index a6e60a9..b93f910 100644 --- a/tests/unit/disruptor_mouse_aim_test.cpp +++ b/tests/unit/disruptor_mouse_aim_test.cpp @@ -463,12 +463,10 @@ void test_capture_release_and_frame_motion() { g_mouse.log = std::tmpfile(); psx_write_byte(kTestYawAddress, 100); - g_buttons = SDL_BUTTON_MMASK; mouse_aim_frame(); expect(g_mouse.captured && g_relative_mode, - "middle-click captures the relative mouse"); + "the game holds the mouse as soon as its window has focus, with no click"); - g_buttons = 0; g_relative_x = 25.0f; mouse_aim_frame(); expect(psx_read_byte(kTestYawAddress) == 98, @@ -480,24 +478,30 @@ void test_capture_release_and_frame_motion() { g_buttons = SDL_BUTTON_MMASK; mouse_aim_frame(); expect(!g_mouse.captured && !g_relative_mode, - "second middle-click releases the mouse"); + "a middle click releases the mouse"); expect_close(g_mouse.vertical_pitch, 2.0, "capture release preserves the requested vertical view"); - g_buttons = 0; mouse_aim_frame(); + mouse_aim_frame(); + expect(!g_mouse.captured, "a mouse the player let go stays free while the window keeps focus"); + g_buttons = SDL_BUTTON_MMASK; mouse_aim_frame(); + expect(g_mouse.captured && g_relative_mode, "the next middle click takes it back"); g_buttons = 0; mouse_aim_frame(); g_keys[SDL_SCANCODE_ESCAPE] = 1; mouse_aim_frame(); expect(!g_mouse.captured && !g_relative_mode, "Escape releases a captured mouse"); - g_keys[SDL_SCANCODE_ESCAPE] = 0; - g_buttons = SDL_BUTTON_MMASK; mouse_aim_frame(); + expect(!g_mouse.captured, "and it stays free after Escape"); + + g_buttons = SDL_BUTTON_LMASK; + mouse_aim_frame(); + expect(g_mouse.captured && g_relative_mode, "a left click in the window takes the mouse back"); g_buttons = 0; mouse_aim_frame(); g_window_flags = 0; @@ -506,120 +510,129 @@ void test_capture_release_and_frame_motion() { "focus loss releases a captured mouse"); } -void test_focus_return_restores_a_capture_lost_to_focus() { +void test_focus_and_the_players_choice() { reset_state(); g_mouse.enabled = true; g_mouse.mouse_aim_enabled = true; g_mouse.configured = true; g_mouse.log = std::tmpfile(); - const auto frame = [](SDL_WindowFlags focus, std::uint32_t buttons, bool escape = false) { - g_window_flags = focus; + const SDL_WindowFlags focus = SDL_WINDOW_INPUT_FOCUS; + const auto frame = [](SDL_WindowFlags flags, std::uint32_t buttons, bool escape = false) { + g_window_flags = flags; g_buttons = buttons; g_keys[SDL_SCANCODE_ESCAPE] = escape ? 1 : 0; mouse_aim_frame(); }; - const auto capture = [&frame] { - frame(SDL_WINDOW_INPUT_FOCUS, SDL_BUTTON_MMASK); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - }; frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured && !g_relative_mode, - "focus coming back captures nothing that was not captured"); - - capture(); + expect(!g_mouse.captured && !g_relative_mode, "a window without focus leaves the mouse alone"); + frame(focus, 0); + expect(g_mouse.captured && g_relative_mode, "focus coming takes the mouse"); frame(0, 0); expect(!g_mouse.captured && !g_relative_mode, "focus loss releases the mouse"); frame(0, 0); expect(!g_mouse.captured, "the mouse stays free while the window has no focus"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(g_mouse.captured && g_relative_mode, - "focus coming back takes the mouse again"); + frame(focus, 0); + expect(g_mouse.captured && g_relative_mode, "focus coming back takes the mouse again"); - frame(SDL_WINDOW_INPUT_FOCUS, SDL_BUTTON_MMASK); + frame(focus, SDL_BUTTON_MMASK); expect(!g_mouse.captured, "a middle click gives the mouse up"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, SDL_BUTTON_MMASK); + expect(!g_mouse.captured, "the held button toggles nothing on the next frame"); + frame(focus, 0); frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); expect(!g_mouse.captured && !g_relative_mode, "a mouse given up by a middle click stays free when focus comes back"); + frame(focus, SDL_BUTTON_MMASK); + frame(focus, 0); + expect(g_mouse.captured, "a middle click takes it back"); - capture(); - frame(SDL_WINDOW_INPUT_FOCUS, 0, true); + frame(focus, 0, true); expect(!g_mouse.captured, "Escape gives the mouse up"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); expect(!g_mouse.captured && !g_relative_mode, "a mouse given up by Escape stays free when focus comes back"); - capture(); + frame(0, SDL_BUTTON_LMASK); + expect(!g_mouse.captured, "a click seen while the window has no focus takes nothing"); frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, SDL_BUTTON_MMASK); - expect(!g_mouse.captured && !g_relative_mode, - "a middle click as focus comes back gives up the capture that waited for it"); - frame(SDL_WINDOW_INPUT_FOCUS, SDL_BUTTON_MMASK); - expect(!g_mouse.captured, "the held button toggles nothing on the next frame"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured, "and nothing waits for focus any more"); + frame(focus, SDL_BUTTON_LMASK); + expect(g_mouse.captured && g_relative_mode, "a click in the focused window takes the mouse back"); + frame(focus, 0); - capture(); frame(0, 0); frame(0, SDL_BUTTON_MMASK); - expect(!g_mouse.captured, "a middle click seen while the capture waits gives it up too"); frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured && !g_relative_mode, "and focus coming back takes nothing"); - - capture(); + frame(focus, 0); + expect(!g_mouse.captured && !g_relative_mode, + "a middle click seen while the window has no focus gives the mouse up too"); + frame(0, 0); frame(0, SDL_BUTTON_MMASK); - expect(!g_mouse.captured, "a middle click on the frame focus goes releases the mouse"); + expect(!g_mouse.captured && !g_relative_mode, "a middle click without focus takes nothing yet"); frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured && !g_relative_mode, - "and that was the player giving it up, not the focus taking it"); + frame(focus, 0); + expect(g_mouse.captured, "but the mouse is wanted again and focus brings it"); + + frame(focus, SDL_BUTTON_LMASK); + frame(focus, SDL_BUTTON_LMASK, true); + expect(!g_mouse.captured, "Escape lets the mouse go while the left button is down"); + frame(focus, SDL_BUTTON_LMASK); + expect(!g_mouse.captured, "and the button still held takes nothing back"); + frame(focus, 0); + frame(focus, SDL_BUTTON_LMASK); + frame(focus, 0); + expect(g_mouse.captured, "a new click does"); - capture(); frame(0, 0, true); expect(!g_mouse.captured, "Escape on the frame focus goes releases the mouse"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured && !g_relative_mode, - "and that was the player giving it up as well"); - - capture(); - frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0, true); + frame(focus, 0); expect(!g_mouse.captured && !g_relative_mode, - "Escape on the frame focus comes back keeps a waiting capture from returning"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); - expect(!g_mouse.captured, "and it does not return on the next frame either"); + "and that was the player giving it up, not the focus taking it"); + frame(focus, SDL_BUTTON_LMASK); + frame(focus, 0); + expect(g_mouse.captured, "a click takes it back after that as well"); - capture(); frame(0, 0); - frame(0, 0, true); - frame(0, 0); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0, true); expect(!g_mouse.captured && !g_relative_mode, - "Escape seen while the capture waits for focus gives it up as well"); + "Escape on the frame focus comes back keeps the mouse from being taken"); + frame(focus, 0); + expect(!g_mouse.captured, "and it is not taken on the next frame either"); + frame(focus, SDL_BUTTON_LMASK); + frame(focus, 0); - capture(); frame(0, 0); g_relative_mode_succeeds = false; - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); expect(!g_mouse.captured, "a capture the host refuses is not claimed"); g_relative_mode_succeeds = true; - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); expect(!g_mouse.captured, "and it is not tried again on every frame"); + frame(focus, SDL_BUTTON_LMASK); + expect(g_mouse.captured && g_relative_mode, "a click asks again"); + frame(focus, 0); + + g_relative_mode_succeeds = false; + frame(focus, 0, true); + expect(g_mouse.captured, "an Escape release the host refuses leaves the mouse captured"); + frame(focus, 0); + g_relative_mode_succeeds = true; + frame(focus, 0, true); + expect(!g_mouse.captured && !g_relative_mode, "and the next Escape lets it go"); + frame(focus, 0); + frame(focus, SDL_BUTTON_LMASK); + frame(focus, 0); - capture(); g_relative_mode_succeeds = false; frame(0, 0); expect(g_mouse.captured, "a release the host refuses leaves the mouse captured"); g_relative_mode_succeeds = true; - frame(SDL_WINDOW_INPUT_FOCUS, SDL_BUTTON_MMASK); + frame(focus, SDL_BUTTON_MMASK); expect(!g_mouse.captured, "and the next middle click still gives it up"); - frame(SDL_WINDOW_INPUT_FOCUS, 0); + frame(focus, 0); } void test_high_precision_camera_residual() { @@ -632,12 +645,10 @@ void test_high_precision_camera_residual() { g_mouse.log = std::tmpfile(); psx_write_byte(kTestYawAddress, 100); - g_buttons = SDL_BUTTON_MMASK; mouse_aim_frame(); expect_close(g_camera_yaw_residual, 0.0, "capture starts with zero presentation residual"); - g_buttons = 0; g_relative_x = 6.0f; mouse_aim_frame(); expect(psx_read_byte(kTestYawAddress) == 100, @@ -748,6 +759,73 @@ void test_modern_mouse_buttons_require_capture() { expect_pressed(kPadR1, "mouse X2 maps to psionic selection"); } +void test_the_click_that_takes_the_mouse_is_no_shot() { + reset_state(); + g_mouse.enabled = true; + g_mouse.modern_controls_enabled = true; + g_mouse.configured = true; + g_mouse.log = std::tmpfile(); + const auto frame = [](std::uint32_t buttons, bool escape = false, SDL_WindowFlags flags = SDL_WINDOW_INPUT_FOCUS) { + g_pad_buttons = 0xFFFFu; + g_window_flags = flags; + g_buttons = buttons; + g_keys[SDL_SCANCODE_ESCAPE] = escape ? 1 : 0; + mouse_aim_frame(); + }; + + frame(0); + expect(g_mouse.captured, "modern controls alone hold the mouse"); + frame(SDL_BUTTON_LMASK); + expect_pressed(kPadCross, "a left click of a held mouse fires"); + frame(0, true); + frame(0); + expect(!g_mouse.captured, "Escape lets the mouse go"); + frame(SDL_BUTTON_LMASK); + expect(g_mouse.captured, "a left click takes it back"); + expect_released(kPadCross, "and that click does not fire"); + frame(SDL_BUTTON_LMASK); + expect_released(kPadCross, "nor while the button stays down"); + frame(0); + frame(SDL_BUTTON_LMASK); + expect_pressed(kPadCross, "the next click fires"); + frame(0); + + frame(0, false, 0); + expect(!g_mouse.captured, "the mouse is free without focus"); + frame(SDL_BUTTON_LMASK); + expect(g_mouse.captured, "the click that brings focus back takes the mouse with it"); + expect_released(kPadCross, "and is no shot either"); + frame(0); + + g_buttons = SDL_BUTTON_LMASK; + (void)disruptor_mouse_set_captured(0); + (void)disruptor_mouse_set_captured(1); + frame(SDL_BUTTON_LMASK); + expect(g_mouse.captured, "a menu that closes hands the mouse back"); + expect_released(kPadCross, "and the click that closed it is no shot"); + frame(0); + frame(SDL_BUTTON_LMASK); + expect_pressed(kPadCross, "the click after it fires"); + frame(0); + + g_mouse.modern_controls_enabled = false; + refresh_live_control_state("test-off"); + expect(!g_mouse.captured && !g_relative_mode, "switching every mouse feature off frees the mouse"); + frame(0); + frame(SDL_BUTTON_LMASK); + frame(0); + expect(!g_mouse.captured && !g_relative_mode, "with every mouse feature off nothing takes the mouse, a click neither"); + g_mouse.high_precision_camera = true; + refresh_live_control_state("test-camera-alone"); + frame(0); + frame(0); + expect(!g_mouse.captured && !g_relative_mode, "smooth turning alone, with nothing to turn by, takes no mouse"); + g_mouse.modern_controls_enabled = true; + refresh_live_control_state("test-on"); + frame(0); + expect(g_mouse.captured && g_relative_mode, "and switching a feature on again takes it, with no click"); +} + void test_live_settings_api() { reset_state(); g_mouse.configured = true; @@ -936,7 +1014,19 @@ void test_gameplay_gate_discards_new_vertical_motion() { } // namespace int main(int argc, char **argv) { - if (argc == 2 && std::strcmp(argv[1], "--registration") == 0) { + if (argc == 2 && std::strcmp(argv[1], "--replay") == 0) { + const bool given_up = g_mouse.given_up; + reset_state(); + g_mouse.given_up = given_up; + g_mouse.enabled = g_mouse.mouse_aim_enabled = g_mouse.configured = true; + g_mouse.log = std::tmpfile(); + mouse_aim_frame(); + mouse_aim_frame(); + expect(!g_mouse.captured && !g_relative_mode, "a capsule's replay leaves the mouse alone though its window has focus"); + g_buttons = SDL_BUTTON_MMASK; + mouse_aim_frame(); + expect(g_mouse.captured && g_relative_mode, "until the viewer clicks for it"); + } else if (argc == 2 && std::strcmp(argv[1], "--registration") == 0) { expect(g_registered_hook != nullptr, "environment opt-in registers the frame hook"); } else { @@ -954,10 +1044,11 @@ int main(int argc, char **argv) { test_vertical_direction_inversion_and_clamp(); test_simultaneous_relative_axes_sample(); test_capture_release_and_frame_motion(); - test_focus_return_restores_a_capture_lost_to_focus(); + test_focus_and_the_players_choice(); test_high_precision_camera_residual(); test_modern_keyboard_mapping_and_merge(); test_modern_mouse_buttons_require_capture(); + test_the_click_that_takes_the_mouse_is_no_shot(); test_live_settings_api(); test_vertical_only_capture(); test_host_ui_blocks_polled_mod_input();