From ff1abc36b88500674f67ce3b0f449685fd1bbfe6 Mon Sep 17 00:00:00 2001 From: Brian Degenhardt Date: Sat, 20 Jun 2026 20:27:56 -0700 Subject: [PATCH] arm64: SDL/kmsdrm frontend, headless runners, and handheld defaults (fork-only) pcsx2-sdl (SDL3/kmsdrm handheld frontend), pcsx2-eerunner / pcsx2-vurunner headless JIT regression+divergence tools, the gsrunner libmali CLI flags + Wayland scanner wiring, and the heterogeneous-CPU thread-pinning default. Fork-only tooling/frontends. Co-Authored-By: Ryan Walklin Co-Authored-By: Brian Degenhardt Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 28 + pcsx2-eerunner/CMakeLists.txt | 22 + pcsx2-eerunner/Main.cpp | 3038 +++++++++++++++++++++++++++++++++ pcsx2-gsrunner/CMakeLists.txt | 36 + pcsx2-gsrunner/Main.cpp | 321 +++- pcsx2-sdl/CMakeLists.txt | 35 + pcsx2-sdl/Main.cpp | 936 ++++++++++ pcsx2-vurunner/CMakeLists.txt | 43 + pcsx2-vurunner/Main.cpp | 1115 ++++++++++++ pcsx2/VMManager.cpp | 13 + 10 files changed, 5581 insertions(+), 6 deletions(-) create mode 100644 pcsx2-eerunner/CMakeLists.txt create mode 100644 pcsx2-eerunner/Main.cpp create mode 100644 pcsx2-sdl/CMakeLists.txt create mode 100644 pcsx2-sdl/Main.cpp create mode 100644 pcsx2-vurunner/CMakeLists.txt create mode 100644 pcsx2-vurunner/Main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 197a1e54fb..cb0cc63649 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -70,6 +70,34 @@ else() add_subdirectory(pcsx2-gsrunner EXCLUDE_FROM_ALL) endif() +# eerunner — headless EE (R5900) JIT-vs-interpreter divergence localizer. +# Built on demand (`--target pcsx2-eerunner`); EXCLUDE_FROM_ALL otherwise. +if(ENABLE_EERUNNER) + add_subdirectory(pcsx2-eerunner) +else() + add_subdirectory(pcsx2-eerunner EXCLUDE_FROM_ALL) +endif() + +# vurunner — headless VU microprogram replayer for codegen iteration. Needs +# the recompiler test hooks since it shares the harness's plumbing. +if(ENABLE_VURUNNER) + if(NOT ENABLE_RECOMPILER_TEST_HOOKS) + message(FATAL_ERROR "ENABLE_VURUNNER requires ENABLE_RECOMPILER_TEST_HOOKS=ON.") + endif() + add_subdirectory(pcsx2-vurunner) +else() + add_subdirectory(pcsx2-vurunner EXCLUDE_FROM_ALL) +endif() + +# SDL3 / kmsdrm frontend (handheld target) +if(UNIX AND NOT APPLE) + if(ENABLE_SDL_FRONTEND) + add_subdirectory(pcsx2-sdl) + else() + add_subdirectory(pcsx2-sdl EXCLUDE_FROM_ALL) + endif() +endif() + #------------------------------------------------------------------------------- if(NOT IS_SUPPORTED_COMPILER) message(WARNING " diff --git a/pcsx2-eerunner/CMakeLists.txt b/pcsx2-eerunner/CMakeLists.txt new file mode 100644 index 0000000000..342d1ce38b --- /dev/null +++ b/pcsx2-eerunner/CMakeLists.txt @@ -0,0 +1,22 @@ +add_executable(pcsx2-eerunner) + +if (PACKAGE_MODE) + install(TARGETS pcsx2-eerunner DESTINATION ${CMAKE_INSTALL_BINDIR}) +else() + install(TARGETS pcsx2-eerunner DESTINATION ${CMAKE_SOURCE_DIR}/bin) +endif() + +target_sources(pcsx2-eerunner PRIVATE + Main.cpp +) + +target_include_directories(pcsx2-eerunner PRIVATE + "${CMAKE_BINARY_DIR}/common/include" + "${CMAKE_SOURCE_DIR}/pcsx2" + "${CMAKE_SOURCE_DIR}/tests/ctest/core/recompilers" +) + +target_link_libraries(pcsx2-eerunner PRIVATE + PCSX2_FLAGS + PCSX2 +) diff --git a/pcsx2-eerunner/Main.cpp b/pcsx2-eerunner/Main.cpp new file mode 100644 index 0000000000..74f54962f8 --- /dev/null +++ b/pcsx2-eerunner/Main.cpp @@ -0,0 +1,3038 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// pcsx2-eerunner — headless standalone full-system runner for EE (R5900) +// JIT-vs-interpreter divergence triage. +// +// Modeled closely on pcsx2-gsrunner: it does a full VMManager init on a +// dedicated CPU thread and provides the complete Host:: implementation surface +// a standalone binary needs to link against libpcsx2. Unlike gsrunner it runs +// fully headless (Null GS renderer, no window, synchronous GS, no audio) so the +// run is deterministic frame-to-frame. +// +// --selfcheck loads a savestate, runs N frames under the EE interpreter twice +// from the same savestate, and proves the two per-frame fingerprint streams are +// byte-identical. That run-to-run determinism is the gate before any +// JIT-vs-interp diff (--localize / --repro) is meaningful. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include "common/RedtapeWindows.h" +#endif + +#include "fmt/format.h" + +#include "common/Assertions.h" +#include "common/Console.h" +#include "common/CrashHandler.h" +#include "common/Error.h" +#include "common/FileSystem.h" +#include "common/MemorySettingsInterface.h" +#include "common/Path.h" +#include "common/ProgressCallback.h" +#include "common/StringUtil.h" + +#include "pcsx2/PrecompiledHeader.h" + +#include "pcsx2/Achievements.h" +#include "pcsx2/DebugTools/Debug.h" +#include "pcsx2/GS/GS.h" +#include "pcsx2/Host.h" +#include "pcsx2/INISettingsInterface.h" +#include "pcsx2/ImGui/FullscreenUI.h" +#include "pcsx2/ImGui/ImGuiFullscreen.h" +#include "pcsx2/ImGui/ImGuiManager.h" +#include "pcsx2/Hw.h" +#include "pcsx2/Input/InputManager.h" +#include "pcsx2/Memory.h" +#include "pcsx2/R5900.h" +#include "pcsx2/SIO/Pad/Pad.h" +#include "pcsx2/VMManager.h" + +#include "pcsx2/ee_divtrace.h" + +#include "svnrev.h" + +namespace EERunner +{ + static void InitializeConsole(); + static bool InitializeConfig(); + static void SettingsOverride(); + static bool ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params); +} // namespace EERunner + +enum class RunMode +{ + None, + SelfCheck, + Localize, + Repro, + StepDiff, + Vu0Diff, + ContMem, + SpeedhackDiff, + LiveRun, + Disasm, +}; + +static MemorySettingsInterface s_settings_interface; + +// Parsed command-line state, read by the CPU thread. +static RunMode s_mode = RunMode::None; +static std::string s_iso_path; +static std::string s_savestate_path; +static uint32_t s_frames = 300; +static bool s_no_console = false; +static bool s_contmem_vu0_interp = false; // --vu0-interp modifier for --contmem +static GSRendererType s_renderer = GSRendererType::Null; // --renderer (Null default; vk for Intel/headless) +static std::string s_memdump_prefix; // --memdump : write .{interp,jit}.bin at the last frame + +bool EERunner::InitializeConfig() +{ + EmuFolders::SetAppRoot(); + if (!EmuFolders::SetResourcesDirectory() || !EmuFolders::SetDataDirectory(nullptr)) + return false; + + CrashHandler::SetWriteDirectory(EmuFolders::DataRoot); + + const char* error; + if (!VMManager::PerformEarlyHardwareChecks(&error)) + return false; + + { + const std::string roboto_path = + EmuFolders::GetOverridableResourcePath("fonts" FS_OSPATH_SEPARATOR_STR "Roboto-Regular.ttf"); + const auto roboto_data = FileSystem::MapBinaryFileForRead(roboto_path.c_str()); + if (roboto_data.empty()) + { + Console.ErrorFmt("Failed to load font file '{}'.", roboto_path); + return false; + } + + std::vector fonts; + ImGuiManager::FontInfo fi{}; + fi.data = roboto_data; + fi.exclude_ranges = {}; + fi.face_name = nullptr; + fi.is_emoji_font = false; + fonts.push_back(fi); + + ImGuiManager::SetFonts(std::move(fonts)); + } + + // don't provide an ini path, or bother loading. settings are stored entirely in memory. + MemorySettingsInterface& si = s_settings_interface; + Host::Internal::SetBaseSettingsLayer(&si); + + VMManager::SetDefaultSettings(si, true, true, true, true, true); + + VMManager::Internal::LoadStartupSettings(); + return true; +} + +void Host::CommitBaseSettingChanges() +{ + // nothing to save, settings are entirely in memory +} + +void Host::LoadSettings(SettingsInterface& si, std::unique_lock& lock) +{ +} + +void Host::CheckForSettingsChanges(const Pcsx2Config& old_config) +{ +} + +bool Host::RequestResetSettings(bool folders, bool core, bool controllers, bool hotkeys, bool ui) +{ + // not running any UI, so no settings requests will come in + return false; +} + +void Host::SetDefaultUISettings(SettingsInterface& si) +{ + // nothing +} + +bool Host::LocaleCircleConfirm() +{ + // not running any UI, so no settings requests will come in + return false; +} + +std::unique_ptr Host::CreateHostProgressCallback() +{ + return ProgressCallback::CreateNullProgressCallback(); +} + +void Host::ReportInfoAsync(const std::string_view title, const std::string_view message) +{ + if (!title.empty() && !message.empty()) + INFO_LOG("ReportInfoAsync: {}: {}", title, message); + else if (!message.empty()) + INFO_LOG("ReportInfoAsync: {}", message); +} + +void Host::ReportErrorAsync(const std::string_view title, const std::string_view message) +{ + if (!title.empty() && !message.empty()) + ERROR_LOG("ReportErrorAsync: {}: {}", title, message); + else if (!message.empty()) + ERROR_LOG("ReportErrorAsync: {}", message); +} + +void Host::OpenURL(const std::string_view url) +{ + // noop +} + +bool Host::CopyTextToClipboard(const std::string_view text) +{ + return false; +} + +std::string Host::GetTextFromClipboard() +{ + return std::string(); +} + +void Host::BeginTextInput() +{ + // noop +} + +void Host::EndTextInput() +{ + // noop +} + +std::optional Host::GetTopLevelWindowInfo() +{ + // Headless — never present anything. + WindowInfo wi; + wi.type = WindowInfo::Type::Surfaceless; + return wi; +} + +void Host::OnInputDeviceConnected(const std::string_view identifier, const std::string_view device_name) +{ +} + +void Host::OnInputDeviceDisconnected(const InputBindingKey key, const std::string_view identifier) +{ +} + +void Host::SetMouseMode(bool relative_mode, bool hide_cursor) +{ +} + +void Host::SetMouseLock(bool state) +{ +} + +std::optional Host::AcquireRenderWindow(bool recreate_window) +{ + // Headless — the Null renderer doesn't need a surface. + WindowInfo wi; + wi.type = WindowInfo::Type::Surfaceless; + return wi; +} + +void Host::ReleaseRenderWindow() +{ +} + +void Host::BeginPresentFrame() +{ + // Headless — nothing to present. +} + +void Host::RequestResizeHostDisplay(s32 width, s32 height) +{ +} + +void Host::OnVMStarting() +{ +} + +void Host::OnVMStarted() +{ +} + +void Host::OnVMDestroyed() +{ +} + +void Host::OnVMPaused() +{ +} + +void Host::OnVMResumed() +{ +} + +void Host::OnGameChanged(const std::string& title, const std::string& elf_override, const std::string& disc_path, + const std::string& disc_serial, u32 disc_crc, u32 current_crc) +{ +} + +void Host::OnPerformanceMetricsUpdated() +{ +} + +void Host::OnSaveStateLoading(const std::string_view filename) +{ +} + +void Host::OnSaveStateLoaded(const std::string_view filename, bool was_successful) +{ +} + +void Host::OnSaveStateSaved(const std::string_view filename) +{ +} + +void Host::RunOnCPUThread(std::function function, bool block /* = false */) +{ + pxFailRel("Not implemented"); +} + +void Host::RefreshGameListAsync(bool invalidate_cache) +{ +} + +void Host::CancelGameListRefresh() +{ +} + +bool Host::IsFullscreen() +{ + return false; +} + +void Host::SetFullscreen(bool enabled) +{ +} + +void Host::OnCaptureStarted(const std::string& filename) +{ +} + +void Host::OnCaptureStopped() +{ +} + +void Host::RequestExitApplication(bool allow_confirm) +{ +} + +void Host::RequestExitBigPicture() +{ +} + +void Host::RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool default_save_state) +{ + VMManager::SetState(VMState::Stopping); +} + +void Host::OnAchievementsLoginSuccess(const char* username, u32 points, u32 sc_points, u32 unread_messages) +{ + // noop +} + +void Host::OnAchievementsLoginRequested(Achievements::LoginRequestReason reason) +{ + // noop +} + +void Host::OnAchievementsHardcoreModeChanged(bool enabled) +{ + // noop +} + +void Host::OnAchievementsRefreshed() +{ + // noop +} + +void Host::OnCoverDownloaderOpenRequested() +{ + // noop +} + +void Host::OnCreateMemoryCardOpenRequested() +{ + // noop +} + +bool Host::InBatchMode() +{ + return false; +} + +bool Host::InNoGUIMode() +{ + return false; +} + +bool Host::ShouldPreferHostFileSelector() +{ + return false; +} + +void Host::OpenHostFileSelectorAsync(std::string_view title, bool select_directory, FileSelectorCallback callback, + FileSelectorFilters filters, std::string_view initial_directory) +{ + callback(std::string()); +} + +int Host::LocaleSensitiveCompare(std::string_view lhs, std::string_view rhs) +{ + const int res = std::strncmp(lhs.data(), rhs.data(), std::min(lhs.size(), rhs.size())); + if (res != 0) + return res; + return lhs.size() > rhs.size() ? 1 : (lhs.size() < rhs.size() ? -1 : 0); +} + +std::optional InputManager::ConvertHostKeyboardStringToCode(const std::string_view str) +{ + return std::nullopt; +} + +std::optional InputManager::ConvertHostKeyboardCodeToString(u32 code) +{ + return std::nullopt; +} + +const char* InputManager::ConvertHostKeyboardCodeToIcon(u32 code) +{ + return nullptr; +} + +BEGIN_HOTKEY_LIST(g_host_hotkeys) +END_HOTKEY_LIST() + +void Host::PumpMessagesOnCPUThread() +{ + // Headless — no platform message pump. +} + +s32 Host::Internal::GetTranslatedStringImpl( + const std::string_view context, const std::string_view msg, char* tbuf, size_t tbuf_space) +{ + if (msg.size() > tbuf_space) + return -1; + else if (msg.empty()) + return 0; + + std::memcpy(tbuf, msg.data(), msg.size()); + return static_cast(msg.size()); +} + +std::string Host::TranslatePluralToString(const char* context, const char* msg, const char* disambiguation, int count) +{ + TinyString count_str = TinyString::from_format("{}", count); + + std::string ret(msg); + for (;;) + { + std::string::size_type pos = ret.find("%n"); + if (pos == std::string::npos) + break; + + ret.replace(pos, 2, count_str.view()); + } + + return ret; +} + +static void PrintCommandLineVersion() +{ + std::fprintf(stderr, "PCSX2 EE Runner Version %s\n", GIT_REV); + std::fprintf(stderr, "https://pcsx2.net/\n"); + std::fprintf(stderr, "\n"); +} + +static void PrintCommandLineHelp(const char* progname) +{ + PrintCommandLineVersion(); + std::fprintf(stderr, "Usage: %s [--stepdiff|--contmem|--liverun|--vu0diff|--localize|--repro|--selfcheck] --savestate --frames N [--iso ] []\n", progname); + std::fprintf(stderr, "\n"); + std::fprintf(stderr, " --stepdiff: Checkpoint-anchored interp-vs-JIT diff (THE primary mode). Per frame, checkpoints\n"); + std::fprintf(stderr, " the VM and runs one frame interp-twice + JIT-once from the IDENTICAL state, so a\n"); + std::fprintf(stderr, " clean interp control + JIT divergence = a real EE JIT bug; then zooms to the block.\n"); + std::fprintf(stderr, " --contmem: Continuous-trajectory memory diff. Runs interp CONTINUOUSLY (x2, control) + JIT\n"); + std::fprintf(stderr, " CONTINUOUSLY, diffs per-frame memory hashes. Catches ACCUMULATION bugs --stepdiff\n"); + std::fprintf(stderr, " can't (it re-anchors to golden each frame). Add --vu0-interp to force VU0=interp in\n"); + std::fprintf(stderr, " all passes (isolate EE-FPU/integer from VU0/COP2). Run on x86 too for cross-arch.\n"); + std::fprintf(stderr, " --speedhack-diff: Speedhack-misfire differential. EE-jit throughout; baseline = all transparency-class\n"); + std::fprintf(stderr, " speedhacks OFF (run twice for the determinism floor), then sweeps each speedhack on its\n"); + std::fprintf(stderr, " own (WaitLoop/IntcStat/vuFlagHack/vu1Instant/fastCDVD) + all-on. A speedhack that claims\n"); + std::fprintf(stderr, " to be architecturally transparent must NOT change the EE-RAM trajectory before the\n"); + std::fprintf(stderr, " baseline control floor breaks; the first such divergence (with ReportMemDiff at it) is a\n"); + std::fprintf(stderr, " misfire (e.g. the Burnout-3 WaitLoop timeout-loop skip). Diffs EE main RAM + scratchpad.\n"); + std::fprintf(stderr, " --liverun: Reproduce the in-game HANG headlessly: single EE-jit pass with the LIVE subsystems\n"); + std::fprintf(stderr, " the diff modes suppress (real GS so GIF is consumed, MTVU on). A 10s no-frame-\n"); + std::fprintf(stderr, " progress watchdog samples the live EE PC to fingerprint the spin loop, then exits 42.\n"); + std::fprintf(stderr, " --vu0diff: Pin EE interp in both passes, toggle only VU0 micro engine; diff COP2 read-streams.\n"); + std::fprintf(stderr, " --localize / --repro: aliases of --stepdiff (the old jittery frame-boundary funnel was removed).\n"); + std::fprintf(stderr, " --selfcheck: Characterize run-to-run determinism (interp only). Expected to flag benign ~10-cycle\n"); + std::fprintf(stderr, " pause-point sampling jitter; use it to understand the noise floor, not as a gate.\n"); + std::fprintf(stderr, " --renderer : GS renderer (default null). Use vk on Intel GPUs / boxes where\n"); + std::fprintf(stderr, " the auto-check declines Vulkan and the surfaceless GL path fails to open GS.\n"); + std::fprintf(stderr, " --savestate : Savestate to load after Initialize (required).\n"); + std::fprintf(stderr, " --frames N: Number of frames to run (default 300).\n"); + std::fprintf(stderr, " --iso : Game ISO/disc to mount (required so the savestate has its disc).\n"); + std::fprintf(stderr, " -help: Displays this information and exits.\n"); + std::fprintf(stderr, " -version: Displays version information and exits.\n"); + std::fprintf(stderr, "\n"); +} + +void EERunner::InitializeConsole() +{ + const char* var = std::getenv("PCSX2_NOCONSOLE"); + s_no_console = (var && StringUtil::FromChars(var).value_or(false)); + if (!s_no_console) + Log::SetConsoleOutputLevel(LOGLEVEL_DEBUG); +} + +bool EERunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params) +{ + bool no_more_args = false; + for (int i = 1; i < argc; i++) + { + if (!no_more_args) + { +#define CHECK_ARG(str) !std::strcmp(argv[i], str) +#define CHECK_ARG_PARAM(str) (!std::strcmp(argv[i], str) && ((i + 1) < argc)) + + if (CHECK_ARG("-help") || CHECK_ARG("--help")) + { + PrintCommandLineHelp(argv[0]); + return false; + } + else if (CHECK_ARG("-version") || CHECK_ARG("--version")) + { + PrintCommandLineVersion(); + return false; + } + else if (CHECK_ARG("--selfcheck")) + { + s_mode = RunMode::SelfCheck; + continue; + } + else if (CHECK_ARG("--localize")) + { + s_mode = RunMode::Localize; + continue; + } + else if (CHECK_ARG("--repro")) + { + s_mode = RunMode::Repro; + continue; + } + else if (CHECK_ARG("--stepdiff")) + { + s_mode = RunMode::StepDiff; + continue; + } + else if (CHECK_ARG("--vu0diff")) + { + s_mode = RunMode::Vu0Diff; + continue; + } + else if (CHECK_ARG("--contmem")) + { + s_mode = RunMode::ContMem; + continue; + } + else if (CHECK_ARG("--speedhack-diff")) + { + s_mode = RunMode::SpeedhackDiff; + continue; + } + else if (CHECK_ARG("--liverun")) + { + s_mode = RunMode::LiveRun; + continue; + } + else if (CHECK_ARG("--disasm")) + { + // --disasm: load the savestate, then disassemble EE code in + // [EERUNNER_DIS_LO, EERUNNER_DIS_HI] with the correct R5900 disassembler + // (generic MIPS disassemblers garble R5900 COP2/MMI opcodes). No run. + s_mode = RunMode::Disasm; + continue; + } + else if (CHECK_ARG("--vu0-interp")) + { + s_contmem_vu0_interp = true; + continue; + } + else if (CHECK_ARG_PARAM("--memdump")) + { + s_memdump_prefix = StringUtil::StripWhitespace(argv[++i]); + continue; + } + else if (CHECK_ARG_PARAM("--renderer")) + { + const std::string_view r = StringUtil::StripWhitespace(argv[++i]); + if (r == "null" || r == "Null") s_renderer = GSRendererType::Null; + else if (r == "vk" || r == "vulkan") s_renderer = GSRendererType::VK; + else if (r == "ogl" || r == "gl" || r == "opengl") s_renderer = GSRendererType::OGL; + else if (r == "sw" || r == "software") s_renderer = GSRendererType::SW; + else + { + Console.Error("--renderer expects one of: null, vk, ogl, sw"); + return false; + } + continue; + } + else if (CHECK_ARG_PARAM("--iso")) + { + s_iso_path = StringUtil::StripWhitespace(argv[++i]); + continue; + } + else if (CHECK_ARG_PARAM("--savestate")) + { + s_savestate_path = StringUtil::StripWhitespace(argv[++i]); + continue; + } + else if (CHECK_ARG_PARAM("--frames")) + { + const auto v = StringUtil::FromChars(argv[++i]); + if (!v.has_value() || v.value() == 0) + { + Console.Error("Invalid --frames value."); + return false; + } + s_frames = v.value(); + continue; + } + else if (CHECK_ARG("--")) + { + no_more_args = true; + continue; + } + else if (argv[i][0] == '-') + { + Console.Error("Unknown parameter: '%s'", argv[i]); + return false; + } + +#undef CHECK_ARG +#undef CHECK_ARG_PARAM + } + + // Positional argument = the ISO/disc. + if (s_iso_path.empty()) + s_iso_path = argv[i]; + else + { + Console.Error("Unexpected extra positional argument: '%s'", argv[i]); + return false; + } + } + + if (s_mode == RunMode::None) + { + Console.Error("No mode specified (use --stepdiff, --contmem, --speedhack-diff, --liverun, --vu0diff, --selfcheck, --localize, or --repro)."); + return false; + } + + if (s_savestate_path.empty()) + { + Console.Error("No savestate provided (use --savestate )."); + return false; + } + if (!FileSystem::FileExists(s_savestate_path.c_str())) + { + Console.ErrorFmt("Savestate '{}' does not exist.", s_savestate_path); + return false; + } + + if (s_iso_path.empty()) + { + Console.Error("No ISO provided (use --iso ); the savestate needs its disc mounted."); + return false; + } + if (!FileSystem::FileExists(s_iso_path.c_str())) + { + Console.ErrorFmt("ISO '{}' does not exist.", s_iso_path); + return false; + } + + params.filename = s_iso_path; + return true; +} + +void EERunner::SettingsOverride() +{ + // Headless + deterministic: Null GS renderer, synchronous GS on the CPU + // thread, no MTVU, no audio, no time-stretch. Keep wall-clock out of the + // system clock so two runs from the same savestate produce identical + // architectural state. + + // GS renderer. Default Null (no GS draws) — but Null is NOT self-contained: GS's + // GetAPIForRenderer() has no Null case, so it falls through to GetPreferredRenderer() + // for the HOST device API. On Asahi/AMD/NVIDIA that resolves to Vulkan (works with + // the Surfaceless window we hand back); on an Intel box the auto-check declines Intel + // Vulkan and picks OpenGL, which can't make a context for a Surfaceless window -> GS + // fails to open. There, pass `--renderer vk` to force Vulkan (surfaceless-capable). + // The renderer is irrelevant to EE/--contmem results (both passes on a box use the + // same one, so GS cancels out in the jit-vs-interp diff). + // LiveRun (the in-game hang repro) needs the live subsystems the deterministic + // diff modes suppress: a REAL GS (so GIF/PATH3 is actually consumed, not dropped + // like Null) and MTVU. Null GS is meaningless for it, so force VK if unset. + const bool live = (s_mode == RunMode::LiveRun); + GSRendererType rend = s_renderer; + if (live && rend == GSRendererType::Null) + rend = GSRendererType::VK; + s_settings_interface.SetIntValue("EmuCore/GS", "Renderer", static_cast(rend)); + + // Run GS synchronously on the CPU thread (no MTGS). Key is "SynchronousMTGS" + // (Pcsx2Config::GSOptions::SynchronousMTGS; DEVBUILD-only — Devel defines it). + // Diff modes keep it forced true. For LiveRun, default true but gate on + // EERUNNER_SYNCMTGS: real async play uses SyncMTGS=false, so this lets us test + // whether the MTVU+SyncMTGS combo (which can't occur in normal play) is itself + // the deadlock trigger (harness artifact) vs a genuine hang reproducible async. + bool sync_mtgs = true; + if (live) + { + if (const char* e = std::getenv("EERUNNER_SYNCMTGS")) + sync_mtgs = (e[0] != '0'); + } + s_settings_interface.SetBoolValue("EmuCore/GS", "SynchronousMTGS", sync_mtgs); + + // MTVU off for the deterministic diff modes; ON for LiveRun by default. Gate on + // EERUNNER_MTVU so the wedge can be retested MTVU-off without a rebuild: if the + // hang reproduces identically MTVU-off, it is NOT an MTVU/MTGS thread deadlock + // (rules out "EE thread blocked") and is a genuine EE-rec cycle/event-test bug. + bool live_mtvu = live; + if (live) + { + if (const char* e = std::getenv("EERUNNER_MTVU")) + live_mtvu = (e[0] != '0'); + } + s_settings_interface.SetBoolValue("EmuCore/Speedhacks", "vuThread", live_mtvu); + + // EE recompiler ON by default for LiveRun; gate on EERUNNER_EE=interp (or 0) to + // run the clean EE-interpreter control pass — for jit-vs-interp comparison of the + // SAME live hang (interp clean, jit hangs). Diff modes leave EnableEE untouched. + if (live) + { + bool ee_jit = true; + if (const char* e = std::getenv("EERUNNER_EE")) + ee_jit = !(e[0] == 'i' || e[0] == 'I' || e[0] == '0'); + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", ee_jit); + } + + // EERUNNER_FPUFULL=1 forces CHECK_FPU_FULL (eeClampMode:3) so the EE-FPU JIT uses + // the double-precision ADD/SUB/MUL/MADD paths that match the interp's fpuDouble() + // math. In --liverun: decisive test of the "EE-FPU 1-ULP precision is the hang + // cause" hypothesis (proven NEGATIVE — still hangs with FPUFULL on). In --stepdiff: + // converges the benign mul/add 1-ULP so the lockstep differ walks past it to the + // next (non-precision) divergence. Applied to ALL modes (set before VMManager + // init). recDIV_S stays single even in FULL, so div divergences still surface. + if (const char* e = std::getenv("EERUNNER_FPUFULL")) + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "fpuFullMode", e[0] != '0'); + + // EERUNNER_NOFASTMEM=1 disables EE/VTLB fastmem (the 4 GB signal-backpatch fast + // path). REQUIRED when running this binary under x86 emulation (FEX inside the + // muvm 4K-page microVM, for single-machine cross-arch jit-vs-jit): PCSX2 fastmem + // catches its OWN SIGSEGV to backpatch VTLB accesses, but FEX intercepts SIGSEGV + // for its x86->arm64 translation, so the guest fault never reaches PCSX2's handler + // -> unhandled SIGSEGV right after "Resetting fastmem mappings". Fastmem off routes + // every load/store through the explicit VTLB call path: EE architectural state is + // IDENTICAL (fastmem only changes speed), so memdump/contmem results are unaffected. + // Native arm64 leaves it on (default). Applied to ALL modes (set before VM init). + if (const char* e = std::getenv("EERUNNER_NOFASTMEM")) + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableFastmem", e[0] == '0'); + + // EERUNNER_DIVCHOP=1 forces EE FPU DIV.S/SQRT.S to round toward zero (chop) by + // setting FPUDiv.Roundmode = ChopZero (3), making FPUDivFPCR == FPUFPCR so the + // arm64 recDIV_S FPCR round-mode swap-to-Nearest becomes a no-op. Diagnostic for + // the Burnout-3 hang: the JIT div rounds Nearest (default FPUDivFPCR), interp + // rounds chop (ambient FPUFPCR), and one game div (0.1*2560) lands on the + // 255.9999847/256.0 boundary -> cvt.w.s gives 255 vs 256, a 1-off count that + // corrupts the GIF scratchpad buffer bookkeeping. Roundmode: 0=Nearest 3=ChopZero. + if (const char* e = std::getenv("EERUNNER_DIVCHOP")) + s_settings_interface.SetIntValue("EmuCore/CPU", "FPUDiv.Roundmode", e[0] != '0' ? 3 : 0); + + // No audio output, and no time-stretch sync (wall-clock-driven). Keys live in + // Pcsx2Config::SPU2Options::LoadSave under [SPU2/Output]: Backend / SyncMode. + s_settings_interface.SetStringValue("SPU2/Output", "Backend", "Null"); + s_settings_interface.SetStringValue("SPU2/Output", "SyncMode", "Disabled"); + + // No frameskip. + s_settings_interface.SetBoolValue("EmuCore/GS", "FrameSkipEnable", false); + s_settings_interface.SetIntValue("EmuCore/GS", "FramesToDraw", 1); + s_settings_interface.SetIntValue("EmuCore/GS", "FramesToSkip", 0); + + // Don't limit speed (also set on the VM via SetLimiterMode after init). + s_settings_interface.SetBoolValue("EmuCore/GS", "FrameLimitEnable", false); + s_settings_interface.SetIntValue("EmuCore/GS", "VsyncEnable", 0); + + // Disable input sources; we drive nothing. + s_settings_interface.SetBoolValue("InputSources", "SDL", false); + s_settings_interface.SetBoolValue("InputSources", "XInput", false); + Pad::ClearPortBindings(s_settings_interface, 0); + s_settings_interface.ClearSection("Hotkeys"); + + // Logging. + s_settings_interface.SetBoolValue("Logging", "EnableSystemConsole", !s_no_console); + s_settings_interface.SetBoolValue("Logging", "EnableTimestamps", false); + s_settings_interface.SetBoolValue("Logging", "EnableVerbose", true); + + // Remove memory cards, so we don't have sharing violations. + for (u32 i = 0; i < 2; i++) + { + s_settings_interface.SetBoolValue("MemoryCards", fmt::format("Slot{}_Enable", i + 1).c_str(), false); + s_settings_interface.SetStringValue("MemoryCards", fmt::format("Slot{}_Filename", i + 1).c_str(), ""); + } +} + +// Snapshot live cpuRegs/fpuRegs into a FullSnap (frame-boundary capture; pc/cycle +// from the live registers). +static ee_divtrace::FullSnap CaptureFullSnap() +{ + ee_divtrace::FullSnap fs; + std::memcpy(&fs.cpu, &cpuRegs, sizeof(cpuRegisters)); + std::memcpy(&fs.fpu, &fpuRegs, sizeof(fpuRegisters)); + fs.cycle = cpuRegs.cycle; + fs.pc = cpuRegs.pc; + fs._pad = 0; + return fs; +} + +// Per-frame selfcheck record: full register snapshot + the frame memory hash. +struct SelfCheckFrame +{ + ee_divtrace::FullSnap snap; + uint64_t memhash; +}; + +// Field-level diff of two FullSnaps (defined later); empty == identical regs. +static std::vector DiffFullSnaps(const ee_divtrace::FullSnap& jit, + const ee_divtrace::FullSnap& interp); + +// Advance the loaded VM by `count` frames, discarding output (defined later). +static void AdvanceFrames(uint32_t count); + +// Run s_frames frames in the current CPU mode, retaining a full register +// snapshot + memory hash per frame so selfcheck can field-diff the first +// divergent frame. (Snapshots are ~2 KB each; bounded by s_frames.) +static std::vector RunAndSnapshot() +{ + std::vector out; + out.reserve(s_frames); + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + VMManager::FrameAdvance(1); + VMManager::Execute(); // returns after one frame (paused) + out.push_back({CaptureFullSnap(), ee_divtrace::HashMemory()}); + } + return out; +} + +// Re-run from a fresh savestate load to the state captured at frame index +// `frame` (== AdvanceFrames(frame+1)), returning EE main RAM + scratchpad bytes +// for offline region diffing. Used to localize a store-path divergence. +static std::vector RunToFrameCaptureMem(uint32_t frame) +{ + Error error; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("RunToFrameCaptureMem: load failed: {}", error.GetDescription()); + return {}; + } + AdvanceFrames(frame + 1); + std::vector out(Ps2MemSize::MainRam + Ps2MemSize::Scratch); + std::memcpy(out.data(), eeMem->Main, Ps2MemSize::MainRam); + std::memcpy(out.data() + Ps2MemSize::MainRam, eeMem->Scratch, Ps2MemSize::Scratch); + return out; +} + +// Count of differing 4 KB pages / bytes between two EE-memory captures. +struct MemDiffCount +{ + size_t pages = 0; + size_t bytes = 0; +}; + +// Diff two EE-memory captures; print the first few differing 4 KB pages with a +// little content from each side so the divergent region/device can be reasoned +// about (main RAM byte offset == EE physical address for the first 32 MB). +// Returns the total differing page/byte counts (used by --speedhack-diff to size +// a divergence against the baseline determinism floor). verbose=false suppresses +// the per-page detail + summary line (for the many quiet trajectory samples). +static MemDiffCount ReportMemDiff(const std::vector& a, const std::vector& b, bool verbose = true) +{ + if (a.size() != b.size() || a.empty()) + { + if (verbose) + Console.ErrorFmt(" mem capture size mismatch ({} vs {})", a.size(), b.size()); + return {}; + } + const size_t pageSize = 0x1000; + size_t shown = 0, diffPages = 0, diffBytes = 0; + for (size_t off = 0; off < a.size(); off += pageSize) + { + const size_t end = std::min(off + pageSize, a.size()); + size_t firstDiff = SIZE_MAX, pageDiffBytes = 0; + for (size_t i = off; i < end; ++i) + if (a[i] != b[i]) + { + if (firstDiff == SIZE_MAX) + firstDiff = i; + ++pageDiffBytes; + } + if (firstDiff == SIZE_MAX) + continue; + ++diffPages; + diffBytes += pageDiffBytes; + if (verbose && shown < 8) + { + ++shown; + const char* region = (firstDiff < Ps2MemSize::MainRam) ? "Main" : "Scratch"; + const size_t addr = (firstDiff < Ps2MemSize::MainRam) + ? firstDiff : (firstDiff - Ps2MemSize::MainRam); + Console.ErrorFmt(" {} @ {:#010x}: {} bytes differ in page; A=[{:02x} {:02x} {:02x} {:02x}] B=[{:02x} {:02x} {:02x} {:02x}]", + region, addr, pageDiffBytes, + a[firstDiff], a[firstDiff + 1 < a.size() ? firstDiff + 1 : firstDiff], + a[firstDiff + 2 < a.size() ? firstDiff + 2 : firstDiff], a[firstDiff + 3 < a.size() ? firstDiff + 3 : firstDiff], + b[firstDiff], b[firstDiff + 1 < b.size() ? firstDiff + 1 : firstDiff], + b[firstDiff + 2 < b.size() ? firstDiff + 2 : firstDiff], b[firstDiff + 3 < b.size() ? firstDiff + 3 : firstDiff]); + } + } + if (verbose) + Console.ErrorFmt(" mem diff summary: {} pages, {} bytes differ (of {} captured)", diffPages, diffBytes, a.size()); + return {diffPages, diffBytes}; +} + +// Compare two per-frame snapshot streams; print the first few divergent frames +// with field detail. Returns true if identical. `la`/`lb` label the two streams. +static bool CompareStreams(const std::vector& a, + const std::vector& b, const char* la, const char* lb) +{ + if (a.size() != b.size()) + { + Console.ErrorFmt(" {} vs {}: frame count differs ({} vs {})", la, lb, a.size(), b.size()); + return false; + } + + size_t first_regdiff = SIZE_MAX, first_memdiff = SIZE_MAX, first_pcdiff = SIZE_MAX; + size_t diverged_frames = 0; + for (size_t f = 0; f < a.size(); ++f) + { + const auto regdiffs = DiffFullSnaps(a[f].snap, b[f].snap); + const bool memdiff = a[f].memhash != b[f].memhash; + const bool pcdiff = a[f].snap.pc != b[f].snap.pc; + if (regdiffs.empty() && !memdiff && !pcdiff) + continue; + + ++diverged_frames; + if (!regdiffs.empty() && first_regdiff == SIZE_MAX) + first_regdiff = f; + if (memdiff && first_memdiff == SIZE_MAX) + first_memdiff = f; + if (pcdiff && first_pcdiff == SIZE_MAX) + first_pcdiff = f; + + if (diverged_frames <= 4) + { + Console.ErrorFmt(" [{} vs {}] frame {}: pc {:#010x}/{:#010x} cycle {}/{} (dcyc={}) mem {}", + la, lb, f, a[f].snap.pc, b[f].snap.pc, a[f].snap.cycle, b[f].snap.cycle, + (int64_t)a[f].snap.cycle - (int64_t)b[f].snap.cycle, + memdiff ? "DIFFERS" : "same"); + for (const auto& d : regdiffs) + Console.ErrorFmt(" {}", d); // here "JIT="==la, "INTERP="==lb + } + } + + if (diverged_frames == 0) + { + Console.WriteLn(fmt::format(" {} vs {}: identical ({} frames)", la, lb, a.size())); + return true; + } + + Console.ErrorFmt(" {} vs {}: {}/{} frames diverged. first reg={} mem={} pc={}", + la, lb, diverged_frames, a.size(), + first_regdiff == SIZE_MAX ? -1 : (int64_t)first_regdiff, + first_memdiff == SIZE_MAX ? -1 : (int64_t)first_memdiff, + first_pcdiff == SIZE_MAX ? -1 : (int64_t)first_pcdiff); + return false; +} + +// --selfcheck: N interpreter passes from the same savestate must produce +// byte-identical per-frame snapshot streams. Running 3 passes (not 2) lets us +// distinguish a cold-cache/first-run artifact (B==C but A differs) from genuine +// per-run host nondeterminism (all three differ). Returns process exit code. +static int RunSelfCheck() +{ + Error error; + const int kPasses = 3; + std::vector> runs; + + for (int p = 0; p < kPasses; ++p) + { + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("Failed to load savestate (pass {}): {}", p, error.GetDescription()); + return EXIT_FAILURE; + } + Console.WriteLn(fmt::format("eerunner: pass {} — running {} frames (interp)...", p, s_frames)); + runs.push_back(RunAndSnapshot()); + } + + static const char* const names[3] = {"A", "B", "C"}; + bool all_match = true; + for (int p = 1; p < kPasses; ++p) + all_match &= CompareStreams(runs[p - 1], runs[p], names[p - 1], names[p]); + + if (all_match) + { + Console.WriteLn(fmt::format("SELFCHECK PASS ({} passes × {} frames identical)", kPasses, s_frames)); + return EXIT_SUCCESS; + } + + // Localize the first store-path divergence between the two WARM runs (B,C): + // these have no cold-cache asymmetry, so a memory diff there is genuine + // per-run nondeterminism. Re-run twice to that frame and report the region. + size_t warm_memdiff = SIZE_MAX; + for (size_t f = 0; f < runs[1].size() && f < runs[2].size(); ++f) + if (runs[1][f].memhash != runs[2][f].memhash) + { + warm_memdiff = f; + break; + } + if (warm_memdiff != SIZE_MAX) + { + Console.ErrorFmt("warm-run (B,C) memory first diverges at frame {} — localizing region:", warm_memdiff); + const auto m1 = RunToFrameCaptureMem((uint32_t)warm_memdiff); + const auto m2 = RunToFrameCaptureMem((uint32_t)warm_memdiff); + ReportMemDiff(m1, m2); + } + + Console.Error("SELFCHECK FAIL — see per-pair divergence above."); + Console.Error(" (B vs C identical but A differs => cold-cache/first-run artifact; warm up before the golden.)"); + Console.Error(" (all pairs differ => per-run host nondeterminism; hunt the wall-clock/thread source.)"); + return EXIT_FAILURE; +} + +// =========================================================================== +// --localize : the three-level divergence funnel. +// +// Level 1 (per-FRAME, whole run): run interp golden, then JIT, recording one +// (regfp, memhash) per frame. First differing frame F is the divergent +// frame. If only memhash differs there, it's a store-only divergence (a +// bad memory write that no register has read back yet) — reported at frame +// granularity; finer memory localization is intentionally out of scope (it +// is a slice-tracking tarpit). +// Level 2 (per-OP, frame F only): re-run interp (dense, one Sample/op) and +// JIT (sparse, one Sample per block entry) for frame F with the capture +// sites enabled. Align by walking the JIT block-entry stream against the +// dense interp op-stream — EE rec blocks are single basic blocks, so the +// next interp op with pc == jit[k].pc is unambiguously that block entry. +// First fp mismatch localizes the offending JIT block (the one that ran +// between the last matching entry and the mismatch). +// Level 3 (full register snapshot): re-run frame F with a 1-entry detail +// window at the divergent index on each side, and diff the full +// cpuRegisters/fpuRegisters to name the exact divergent field(s). +// =========================================================================== + +// Switch the EE core between interpreter (jit=false) and recompiler (jit=true). +// Consumed by the next VMManager::Execute() (cpu-impl-changed -> cache clear). +static void SetEeMode(bool jit) +{ + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", jit); + VMManager::ApplySettings(); +} + +// --vu0diff axis: pin the EE INTERPRETER (deterministic, identical in both +// passes) and toggle only the VU0 micro engine. The two passes then run EE-interp +// in lockstep until mVU0-jit first hands the EE a different result than the VU0 +// interpreter — isolating a VU0-jit-vs-interp value bug with no EE-rec or +// cross-arch noise. +static void SetVu0Mode(bool vu0_jit) +{ + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", false); + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableVU0", vu0_jit); + VMManager::ApplySettings(); +} + +// Advance the (already savestate-loaded) VM by `count` frames, discarding output. +static void AdvanceFrames(uint32_t count) +{ + for (uint32_t f = 0; f < count && VMManager::GetState() != VMState::Shutdown; ++f) + { + VMManager::FrameAdvance(1); + VMManager::Execute(); + } +} + +// Checkpoint-anchored fine pass: run exactly ONE frame from the CURRENT VM state +// (a freshly-loaded checkpoint) with the dense/sparse capture sites enabled. No +// AdvanceFrames — the caller positioned the VM, so there is no cross-pass drift. +static std::vector RunFineFpFromHere() +{ + ee_divtrace::Reset(); + ee_divtrace::ReserveStream(16u * 1024u * 1024u); + ee_divtrace::ConfigureFullWindow(0, 0); // fingerprints only + ee_divtrace::g_enabled.store(true, std::memory_order_release); + VMManager::FrameAdvance(1); + VMManager::Execute(); + ee_divtrace::g_enabled.store(false, std::memory_order_release); + return ee_divtrace::g_stream; +} + +// Checkpoint-anchored detail pass: one frame from the current VM state with a +// 1-entry full-snapshot window at stream index `idx`. +static ee_divtrace::FullSnap RunFineSnapAtFromHere(uint32_t idx) +{ + ee_divtrace::Reset(); + ee_divtrace::ReserveStream(16u * 1024u * 1024u); + ee_divtrace::ConfigureFullWindow(idx, 1); + ee_divtrace::g_enabled.store(true, std::memory_order_release); + VMManager::FrameAdvance(1); + VMManager::Execute(); + ee_divtrace::g_enabled.store(false, std::memory_order_release); + if (ee_divtrace::g_snaps.empty()) + return ee_divtrace::FullSnap{}; + return ee_divtrace::g_snaps.front(); +} + +struct AlignResult +{ + bool found = false; + bool control_flow = false; // true: JIT reached a block interp never did + uint32_t jit_idx = 0; // divergent JIT block-entry index + uint32_t interp_idx = 0; // matched interp op index + uint32_t pc = 0; // block entry where divergence was observed + uint32_t prev_pc = 0; // entry of the JIT block that produced it +}; + +// Walk the sparse JIT block-entry stream against the dense interp op-stream. +// +// Semantics: a JIT block-entry sample is (pc=block start, fp=state ABOUT TO +// execute that block). An interp sample is recorded AFTER each op with +// pc=cpuRegs.pc (== the NEXT op) and fp=state after the op == state about to +// execute that next pc. So an interp sample (pc=P, fp=S) means "about to execute +// P with state S" — directly comparable to a JIT block-entry (P, S). +// +// Exception: JIT block-entry #0 is the FRAME START (state == the shared +// checkpoint), and the interp stream has no pre-first-op sample for it (the first +// interp sample with pc==entry0 is one loop iteration later). Entry #0 is equal +// by construction, so we anchor on it without comparing, and begin the real +// divergence search at entry #1. +// Walk from a given resume point (start_k JIT entry, start_ii interp position, +// start_prev_pc the entry that precedes start_k). Returns the first divergence +// at or after start_k, or {found=false} if the streams agree to the end. +static AlignResult AlignFrom(const std::vector& interp, + const std::vector& jit, size_t start_k, size_t start_ii, uint32_t start_prev_pc) +{ + size_t ii = start_ii; + uint32_t prev_pc = start_prev_pc; + for (size_t k = start_k; k < jit.size(); ++k) + { + size_t scan = ii; + while (scan < interp.size() && interp[scan].pc != jit[k].pc) + ++scan; + if (scan >= interp.size()) + return {true, true, static_cast(k), static_cast(ii), jit[k].pc, prev_pc}; + if (interp[scan].fp != jit[k].fp) + return {true, false, static_cast(k), static_cast(scan), jit[k].pc, prev_pc}; + ii = scan + 1; + prev_pc = jit[k].pc; + } + return {}; // streams agree across every JIT block entry +} + +static AlignResult Align(const std::vector& interp, + const std::vector& jit) +{ + return AlignFrom(interp, jit, 1, 0, jit.empty() ? 0 : jit[0].pc); +} + +// After a data divergence at (div_k, div_ii) that the caller has classified +// benign (a cycle-derived timer read taints one or more GPRs), walk forward to +// where the two streams RE-CONVERGE — the first JIT block-entry whose pc matches +// the interp op-stream AND whose full fingerprint agrees again (the tainted +// value has been overwritten / washed out). From there a strict walk is sound. +struct ResyncResult +{ + bool reconverged = false; + uint32_t k = 0; // resume start_k for AlignFrom + uint32_t ii = 0; // resume start_ii + uint32_t prev_pc = 0; // resume start_prev_pc + uint32_t blind = 0; // JIT block-entries skipped while contaminated (a blind window) + bool ran_off = false; // pc-match failed mid-window (timing perturbed control flow) +}; + +static ResyncResult ResyncAfter(const std::vector& interp, + const std::vector& jit, uint32_t div_k, uint32_t div_ii) +{ + size_t ii = static_cast(div_ii) + 1; + uint32_t blind = 0; + for (size_t k = div_k + 1; k < jit.size(); ++k) + { + size_t scan = ii; + while (scan < interp.size() && interp[scan].pc != jit[k].pc) + ++scan; + if (scan >= interp.size()) + { + ResyncResult r; + r.ran_off = true; + r.blind = blind; + return r; // JIT reached a block interp never did within the window + } + if (interp[scan].fp == jit[k].fp) + { + // Full state matches again — resume strict align AFTER this entry. + ResyncResult r; + r.reconverged = true; + r.k = static_cast(k) + 1; + r.ii = static_cast(scan) + 1; + r.prev_pc = jit[k].pc; + r.blind = blind; + return r; + } + ii = scan + 1; + ++blind; + } + ResyncResult r; // walked to end-of-frame still divergent (taint never washed out) + r.blind = blind; + return r; +} + +// True if any non-GPR architectural field differs (HI/LO, CP0 except the +// dispatcher counters, FPR, ACC, sa) — i.e. the divergence is more than just +// tainted GPRs, so it can't be dismissed as a pure timer-read artifact. +static bool NonGprDiffers(const ee_divtrace::FullSnap& jit, const ee_divtrace::FullSnap& interp) +{ + if (jit.cpu.HI.UD[0] != interp.cpu.HI.UD[0] || jit.cpu.HI.UD[1] != interp.cpu.HI.UD[1] || + jit.cpu.LO.UD[0] != interp.cpu.LO.UD[0] || jit.cpu.LO.UD[1] != interp.cpu.LO.UD[1]) + return true; + for (int i = 0; i < 32; ++i) + { + if (i == 1 || i == 9 || i == 11 || i == 13 || i == 14) // +Cause/EPC: interrupt-phase noise + continue; + if (jit.cpu.CP0.r[i] != interp.cpu.CP0.r[i]) + return true; + } + if (!ee_divtrace::g_fp_exclude) + { + for (int i = 0; i < 32; ++i) + if (jit.fpu.fpr[i].UL != interp.fpu.fpr[i].UL) + return true; + if (jit.fpu.ACC.UL != interp.fpu.ACC.UL) + return true; + } + if (jit.cpu.sa != interp.cpu.sa) + return true; + return false; +} + +// Field-level diff of two full snapshots, mirroring DiffEe's ignored set +// (CP0 Random/Count/Compare, FPU control regs, cycle bookkeeping). +static std::vector DiffFullSnaps(const ee_divtrace::FullSnap& jit, + const ee_divtrace::FullSnap& interp) +{ + std::vector out; + static const char* const gpr_names[32] = { + "zero", "at", "v0", "v1", "a0", "a1", "a2", "a3", + "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7", + "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", + "t8", "t9", "k0", "k1", "gp", "sp", "s8", "ra"}; + auto d64 = [&](const std::string& n, u64 a, u64 b) { + if (a != b) + out.push_back(fmt::format("{}: JIT={:#018x} INTERP={:#018x}", n, a, b)); + }; + auto d32 = [&](const std::string& n, u32 a, u32 b) { + if (a != b) + out.push_back(fmt::format("{}: JIT={:#010x} INTERP={:#010x}", n, a, b)); + }; + for (int i = 0; i < 32; ++i) + { + d64(std::string(gpr_names[i]) + ".lo", jit.cpu.GPR.r[i].UD[0], interp.cpu.GPR.r[i].UD[0]); + d64(std::string(gpr_names[i]) + ".hi", jit.cpu.GPR.r[i].UD[1], interp.cpu.GPR.r[i].UD[1]); + } + d64("hi.lo", jit.cpu.HI.UD[0], interp.cpu.HI.UD[0]); + d64("hi.hi", jit.cpu.HI.UD[1], interp.cpu.HI.UD[1]); + d64("lo.lo", jit.cpu.LO.UD[0], interp.cpu.LO.UD[0]); + d64("lo.hi", jit.cpu.LO.UD[1], interp.cpu.LO.UD[1]); + for (int i = 0; i < 32; ++i) + { + if (i == 1 || i == 9 || i == 11 || i == 13 || i == 14) // +Cause/EPC: interrupt-phase noise + continue; + d32(fmt::format("cp0[{}]", i), jit.cpu.CP0.r[i], interp.cpu.CP0.r[i]); + } + if (!ee_divtrace::g_fp_exclude) + { + for (int i = 0; i < 32; ++i) + d32(fmt::format("fpr[{}]", i), jit.fpu.fpr[i].UL, interp.fpu.fpr[i].UL); + d32("ACC", jit.fpu.ACC.UL, interp.fpu.ACC.UL); + } + d32("sa", jit.cpu.sa, interp.cpu.sa); + return out; +} + +// True for EE branch/jump primary opcodes (so we can stop disassembling a +// single basic block after its terminating branch + delay slot). +static bool IsEeBranchOpcode(u32 code) +{ + const u32 op = code >> 26; + if (op == 0) // SPECIAL — JR (8) / JALR (9) + { + const u32 fn = code & 0x3f; + return fn == 8 || fn == 9; + } + if (op == 1) // REGIMM — BLTZ/BGEZ/BLTZAL/... + return true; + if (op == 2 || op == 3) // J / JAL + return true; + if (op >= 4 && op <= 7) // BEQ/BNE/BLEZ/BGTZ + return true; + if (op >= 0x14 && op <= 0x17) // BEQL/BNEL/BLEZL/BGTZL + return true; + if (op == 0x10 || op == 0x11 || op == 0x12) // COP0/1/2 — may be BCxF/T + return ((code >> 21) & 0x1f) == 0x08; + return false; +} + +// Disassemble a single EE basic block starting at `pc` to the console (stops a +// couple of instructions past the first branch, or at maxInsns). Read on the +// CPU thread where guest memory is live. +static void DisasmBlock(u32 pc, u32 maxInsns = 48) +{ + Console.WriteLn(fmt::format(" --- block disasm @ {:#010x} ---", pc)); + bool saw_branch = false; + u32 after_branch = 0; + for (u32 i = 0; i < maxInsns; ++i) + { + const u32 addr = pc + i * 4; + const u32 code = memRead32(addr); + std::string line; + R5900::disR5900Fasm(line, code, addr, /*simplify=*/false); + Console.WriteLn(fmt::format(" {:#010x}: {:08x} {}", addr, code, line)); + if (saw_branch && ++after_branch >= 1) // include the delay slot, then stop + break; + if (IsEeBranchOpcode(code)) + saw_branch = true; + } +} + +// Recognize the cycle-derived-MMIO divergence class: a divergent GPR whose value +// originates — directly OR through arithmetic — from an EE timer COUNT register +// read inside the offending block. The EE timers (T0..T3 @ 0x1000_0000 / _0800 / +// _1000 / _1800, COUNT at +0) advance with cpuRegs.cycle, and the JIT vs interp +// differ by a few ticks at any mid-block read because they sync accumulated +// block-cycles at different granularity (per-block vs per-op). That is a TIMING +// artifact, not a codegen bug — a classic timing-taint trap — so we tag it rather +// than presenting it as a real bug. +// +// Two layers run over the block: +// * const-prop (lui/ori/addiu chains) resolves each load's effective address, +// so we can spot a load from a timer COUNT; +// * taint-propagation tracks a per-GPR "cycle-derived" bit: set on a COUNT +// load, propagated through pure-dataflow ALU ops (subu/addu/daddu/sll/and/…) +// whose source is tainted, cleared on a non-timer load / lui / unmodeled +// writer. This catches the common software-timer accumulator shape (read +// COUNT, subtract prior COUNT for a delta, daddu it into a 64-bit virtual +// clock) where the divergence flows into registers that were never the load +// destination. Under-tainting is the SAFE failure mode (the divergence is +// reported as real and a human looks); we only propagate through ops modeled +// as pure dataflow, so we never mark a real-bug value as benign. +// +// Returns a human description for each divergent GPR proven cycle-derived (and +// pushes its index into classified_out), or empty. +static std::vector ClassifyCycleDerivedLoads(u32 block_pc, + const std::vector& divergent_gprs, std::vector* classified_out = nullptr, + u32 maxInsns = 48) +{ + std::vector out; + if (divergent_gprs.empty()) + return out; + + u32 regval[32] = {0}; + bool known[32] = {false}; + bool tainted[32] = {false}; + std::string taint_src[32]; // origin description carried with the taint + known[0] = true; // $zero + + auto setTaint = [&](u32 d, bool t, const std::string& src) { + tainted[d] = t; + taint_src[d] = t ? src : std::string(); + }; + + bool saw_branch = false; + for (u32 i = 0; i < maxInsns; ++i) + { + const u32 addr = block_pc + i * 4; + const u32 code = memRead32(addr); + const u32 op = code >> 26; + const u32 rs = (code >> 21) & 0x1f; + const u32 rt = (code >> 16) & 0x1f; + const u32 rd = (code >> 11) & 0x1f; + const u32 fn = code & 0x3f; + const s32 simm = static_cast(code & 0xffff); + const u32 uimm = code & 0xffff; + + auto isTimerCount = [](u32 a) { + // COUNT register of any of the four EE timers (offset 0 within the + // 0x800-strided bank). Flag the whole COUNT word. + return (a == 0x10000000 || a == 0x10000800 || a == 0x10001000 || a == 0x10001800); + }; + + // Word/dword loads — a timer-COUNT load TAINTS rt; any other load gives rt + // a fresh untainted value. + if (op == 0x23 /*LW*/ || op == 0x27 /*LWU*/ || op == 0x37 /*LD*/ || op == 0x1e /*LQ*/) + { + if (known[rs] && isTimerCount(regval[rs] + static_cast(simm))) + { + const u32 ea = regval[rs] + static_cast(simm); + const int timer = (ea - 0x10000000) / 0x800; + setTaint(rt, true, fmt::format("EE Timer {} COUNT ({:#010x}) read at pc={:#010x}", timer, ea, addr)); + } + else + { + setTaint(rt, false, {}); + } + known[rt] = false; // loaded value is not a tracked const + } + else if (op == 0x10 /*COP0*/ && rs == 0x00 /*MFC0*/ && rd == 9 /*Count*/) + { + // mfc0 rt, $9 reads the COP0 cycle counter directly into rt. Like the + // EE-timer COUNT MMIO loads, its value differs between the JIT and interp + // by the per-block-vs-per-op cycle-sync granularity, so it taints rt. The + // shape here is a Count-based timeout busy-wait (mfc0 Count; dsll32/dsra32 + // sign-extend; sltu vs a deadline) - benign cycle phase, not a codegen bug. + setTaint(rt, true, fmt::format("COP0 Count (mfc0 $9) read at pc={:#010x}", addr)); + known[rt] = false; + } + else if (op == 0x0f /*LUI*/) + { + regval[rt] = uimm << 16; known[rt] = true; + setTaint(rt, false, {}); // immediate — untainted + } + else if (op == 0x0d /*ORI*/) + { + if (known[rs]) { regval[rt] = regval[rs] | uimm; known[rt] = true; } else known[rt] = false; + setTaint(rt, tainted[rs], taint_src[rs]); + } + else if (op == 0x09 /*ADDIU*/ || op == 0x19 /*DADDIU*/ || op == 0x08 /*ADDI*/ || op == 0x18 /*DADDI*/) + { + if (known[rs]) { regval[rt] = regval[rs] + static_cast(simm); known[rt] = true; } else known[rt] = false; + setTaint(rt, tainted[rs], taint_src[rs]); + } + else if (op == 0x0a /*SLTI*/ || op == 0x0b /*SLTIU*/ || op == 0x0c /*ANDI*/ || op == 0x0e /*XORI*/) + { + known[rt] = false; // not const-tracked, but taint flows from rs + setTaint(rt, tainted[rs], taint_src[rs]); + } + else if (op == 0x00 /*SPECIAL*/) + { + // R-type pure-dataflow ALU: dst rd, tainted iff any source operand is. + // Shift-immediate forms (sll/srl/sra/dsll*/dsrl*/dsra*) take only rt. + const bool isShiftImm = + (fn == 0x00 || fn == 0x02 || fn == 0x03 || + fn == 0x38 || fn == 0x3a || fn == 0x3b || + fn == 0x3c || fn == 0x3e || fn == 0x3f); + const bool isAlu = + (fn >= 0x20 && fn <= 0x2f) || // add/addu/sub/subu/and/or/xor/nor/slt/sltu/dadd..dsubu + isShiftImm || + (fn == 0x04 || fn == 0x06 || fn == 0x07) || // sllv/srlv/srav + (fn == 0x14 || fn == 0x16 || fn == 0x17); // dsllv/dsrlv/dsrav + if (isAlu) + { + const bool srcT = isShiftImm ? tainted[rt] : (tainted[rs] || tainted[rt]); + const std::string& src = tainted[rs] ? taint_src[rs] : taint_src[rt]; + setTaint(rd, srcT, src); + // Keep the existing OR const-prop; other R-ops invalidate rd's const. + if (fn == 0x25 /*OR*/ && known[rs] && known[rt]) { regval[rd] = regval[rs] | regval[rt]; known[rd] = true; } + else known[rd] = false; + } + else + { + // Unmodeled SPECIAL GPR writer (mfhi/mflo/movz/…): clear rd taint + // (under-taint = safe). jr/jalr/sync have rd=0, harmless. + setTaint(rd, false, {}); + } + } + // (Stores, branches, COP ops write no GPR we model — taint left intact. + // Any other GPR-writing op we don't recognize is an under-taint, which is + // the safe direction: the divergence is reported as real, not skipped.) + + if (saw_branch) + break; + if (IsEeBranchOpcode(code)) + saw_branch = true; + } + + // Classify each divergent GPR whose FINAL value is cycle-derived. + for (int dr : divergent_gprs) + { + if (tainted[dr]) + { + out.push_back(fmt::format( + "${} is cycle-derived from {} — JIT/interp cycle-sync granularity makes a few-tick delta " + "EXPECTED, almost certainly NOT a codegen bug.", + dr, taint_src[dr])); + if (classified_out) + classified_out->push_back(dr); + } + } + return out; +} + +// Returns the backward-branch target if the basic block at `block_pc` terminates +// in a PC-relative branch that loops back to at/near its own entry (a self-loop), +// else 0. Only PC-relative families (REGIMM, BEQ/BNE/BLEZ/BGTZ, their likely +// variants, COPx BCxF/T) encode a reachable backward target; J/JAL/JR/JALR are +// absolute and not treated as self-loops here. When found, `*branch_addr_out` +// receives the address of the terminating branch (so the caller can bound the +// loop body's pc range, delay slot included). +static u32 BlockBackwardBranchTarget(u32 block_pc, u32* branch_addr_out = nullptr, u32 maxInsns = 64) +{ + for (u32 i = 0; i < maxInsns; ++i) + { + const u32 addr = block_pc + i * 4; + const u32 code = memRead32(addr); + if (!IsEeBranchOpcode(code)) + continue; + const u32 op = code >> 26; + const bool pcrel = (op == 1) || (op >= 4 && op <= 7) || (op >= 0x14 && op <= 0x17) || + ((op == 0x10 || op == 0x11 || op == 0x12) && ((code >> 21) & 0x1f) == 0x08); + if (!pcrel) + return 0; // first terminating branch is absolute — not a self-loop + const s32 off = static_cast(code & 0xffff); + const u32 target = addr + 4 + (static_cast(off) << 2); + // Self-loop: backward branch whose target lands in this block's head + // region (at the entry or a few words before/within it). + if (target <= addr && target + 8 >= block_pc) + { + if (branch_addr_out) + *branch_addr_out = addr; + return target; + } + return 0; // first terminating branch is forward / out-of-block + } + return 0; +} + +// Resync past an ENTIRE self-loop run, not one iteration at a time. The JIT +// records one block-entry per loop iteration (all at the loop head pc), so we +// skip every consecutive JIT entry whose pc is in the loop body range +// [loop_lo, loop_hi] to land on the loop EXIT entry — the first JIT entry past +// the loop, carrying the loop's final architectural state. We then find that +// exact exit in the dense interp stream by (pc AND fingerprint) match. +// +// The fingerprint match (not a pc-range skip) is essential AND is the soundness +// check: a branch's delay slot is sampled by the interpreter with pc = branch+8, +// which aliases the loop's fall-through exit pc and recurs EVERY iteration — so a +// pc-only scan can't tell a mid-loop delay slot from the real exit. Only the true +// exit carries the loop's final state, so pc+fp pins it unambiguously. A pure +// sampling-phase artifact reaches an exit state identical to the JIT's; a real +// loop-body codegen bug changes the exit state or trip count, so the JIT exit +// fingerprint never appears in interp → reported as a real lead. Collapses what +// iteration-at-a-time resync would spend the whole benign-skip budget on into a +// single jump. +static ResyncResult ResyncPastSelfLoop(const std::vector& interp, + const std::vector& jit, uint32_t div_k, uint32_t div_ii, + u32 loop_lo, u32 loop_hi) +{ + auto inLoop = [&](u32 pc) { return pc >= loop_lo && pc <= loop_hi; }; + uint32_t blind = 0; + size_t k = div_k; + while (k < jit.size() && inLoop(jit[k].pc)) { ++k; ++blind; } + if (k >= jit.size()) + { + ResyncResult r; // loop never exited within the frame + r.blind = blind; + return r; + } + const u32 exit_pc = jit[k].pc; + const u64 exit_fp = jit[k].fp; + size_t ii = div_ii; + while (ii < interp.size() && !(interp[ii].pc == exit_pc && interp[ii].fp == exit_fp)) + ++ii; + if (ii >= interp.size()) + { + ResyncResult r; // JIT's loop-exit state never appears in interp — real lead + r.blind = blind; + return r; + } + ResyncResult r; + r.reconverged = true; + r.k = static_cast(k) + 1; + r.ii = static_cast(ii) + 1; + r.prev_pc = exit_pc; + r.blind = blind; + return r; +} + +// Checkpoint-anchored zoom: given a checkpoint file holding the state at the +// START of the divergent frame, localize the offending JIT block and the exact +// divergent register field(s). interp (dense per-op) and JIT (sparse per-block) +// both run ONE frame from the SAME checkpoint, so the alignment is clean — no +// cross-pass drift. Prints the block disasm + field diff + fixture next-step. +// Returns true if the walk found something worth STOPPING for (a real codegen +// divergence, a control-flow split, or an inconclusive lead), false if every +// divergence this frame was a benign cycle-derived timer artifact the walk could +// resync past — in which case the caller should keep scanning later frames. +static bool ZoomFromCheckpoint(const std::string& ckpt) +{ + Error error; + auto reload = [&](bool jit) -> bool { + if (!VMManager::LoadState(ckpt.c_str(), &error)) + { + Console.ErrorFmt("zoom: load checkpoint failed: {}", error.GetDescription()); + return false; + } + SetEeMode(jit); + return true; + }; + + Console.WriteLn("STEPDIFF zoom — dense interp pass (one frame from checkpoint)..."); + if (!reload(false)) + return true; + const auto interp_fine = RunFineFpFromHere(); + + Console.WriteLn("STEPDIFF zoom — sparse JIT pass (one frame from checkpoint)..."); + if (!reload(true)) + return true; + const auto jit_fine = RunFineFpFromHere(); + + Console.WriteLn(fmt::format("STEPDIFF zoom — interp {} ops, JIT {} block entries.", + interp_fine.size(), jit_fine.size())); + + // Iteratively localize. Find the first divergence and classify it: a data + // divergence whose ONLY differing fields are GPRs loaded from EE timer COUNT + // registers is the cycle-sync timing artifact — tag it, resync past where the + // tainted value washes out, and keep hunting. Anything else is a real lead and + // stops the walk. A cap bounds the per-skip re-run cost. + size_t k = 1, ii = 0; + uint32_t prev_pc = jit_fine.empty() ? 0 : jit_fine[0].pc; + // Two skip budgets. Timer skips each take TWO full-frame re-runs to snapshot + // the divergent registers, so they are capped tightly. Self-loop phase skips + // are pure fingerprint-stream walks (no re-run), so they get a far larger cap + // — a single frame can legitimately contain dozens of short phase-misaligned + // string/scan loops, and stopping at 32 would falsely report the 33rd. + int timer_skipped = 0; + int selfloop_skipped = 0; + int interrupt_skipped = 0; + const int kTimerCap = 32; + const int kSelfLoopCap = 4096; + const int kInterruptCap = 4096; // stream-only resync, like self-loops + + while (true) + { + const int benign_skipped = timer_skipped + selfloop_skipped + interrupt_skipped; + const AlignResult ar = AlignFrom(interp_fine, jit_fine, k, ii, prev_pc); + if (!ar.found) + { + if (benign_skipped > 0) + { + Console.WriteLn(fmt::format( + "STEPDIFF zoom: no CODEGEN divergence this frame — walked past {} benign divergence(s) " + "({} cycle-derived timer, {} self-loop phase, {} interrupt-handler phase); the streams " + "otherwise agree to end-of-frame.", + benign_skipped, timer_skipped, selfloop_skipped, interrupt_skipped)); + return false; // benign — caller keeps scanning + } + Console.WriteLn("STEPDIFF zoom: registers diverged at frame granularity but per-op alignment found " + "no block-entry mismatch. The divergence likely lands on state written after the " + "last block boundary (event-test / COP path) — inspect the frame-boundary diff."); + return true; // inconclusive lead — stop for a human look + } + + if (ar.control_flow) + { + // control_flow means jit[k].pc was not found in interp's op-stream + // AFTER the alignment point. Disambiguate a genuine wrong-target branch + // from a benign spin-wait PHASE offset: if that pc appears ANYWHERE in + // interp's stream this frame, the interpreter DID execute it (just at a + // different iteration of a producer/consumer poll loop — e.g. the GIF + // double-buffer wait at 0x1f24e0) and the JIT is merely further ahead at + // the vsync cutoff. Benign — the end-of-frame MEMORY gate (caller) has + // already proven this frame's persisted state differs only by timer/phase + // noise, so a poll-loop iteration imbalance here carries no real signal. + // If the pc appears NOWHERE in interp's stream, the JIT branched to a + // block the interpreter never reached → a real control-flow codegen bug. + bool interp_reached = false; + for (const auto& s : interp_fine) + if (s.pc == ar.pc) { interp_reached = true; break; } + if (interp_reached) + { + Console.WriteLn(fmt::format( + "STEPDIFF zoom: spin-wait PHASE offset entering pc={:#010x} (offending block {:#010x}) — the " + "interpreter DID reach this pc elsewhere this frame; the JIT sits at a different poll-loop " + "iteration at the vsync cutoff (benign, JIT ran off the end of interp's stream). Continuing the hunt.", + ar.pc, ar.prev_pc)); + return false; // benign phase — caller keeps scanning later frames + } + Console.WriteLn(fmt::format( + "STEPDIFF zoom: CONTROL-FLOW divergence — JIT dispatched to block pc={:#010x} the interpreter " + "NEVER reached this frame. Offending JIT block: pc={:#010x} (terminating branch went to the wrong target).", + ar.pc, ar.prev_pc)); + if (ar.prev_pc) + DisasmBlock(ar.prev_pc); + return true; + } + + // Self-loop PHASE misalignment — checked FIRST and CHEAPLY (no snapshot). + // The offending block is a tight backward self-loop; the JIT folds the + // loop's first iteration into the preceding block, so its loop-head sample + // runs one iteration ahead of the interpreter's dense per-op samples. Both + // cores compute the SAME results — a sampling-phase artifact, not a codegen + // bug. ResyncPastSelfLoop works on the fingerprint streams ALONE: it skips + // the whole loop run and proves convergence at the loop EXIT (final state + // identical). A real loop-body bug changes the exit state or trip count and + // will NOT converge → it falls through to the snapshot + real report below. + // Because this needs no re-run, it gets the large self-loop cap, so a frame + // full of short scan loops doesn't exhaust the tight timer budget. + { + u32 loop_branch_addr = 0; + const u32 loop_target = BlockBackwardBranchTarget(ar.pc, &loop_branch_addr); + if (loop_target != 0 && selfloop_skipped < kSelfLoopCap) + { + const u32 loop_lo = loop_target; + const u32 loop_hi = loop_branch_addr + 4; // include the branch's delay slot + const ResyncResult rs = ResyncPastSelfLoop( + interp_fine, jit_fine, ar.jit_idx, ar.interp_idx, loop_lo, loop_hi); + if (rs.reconverged) + { + Console.WriteLn(fmt::format( + "STEPDIFF zoom: skipping self-loop phase misalignment entering pc={:#010x} (loop {:#010x}..{:#010x}, " + "branches back to {:#010x}). The JIT folds the loop's first iteration into the preceding block, so " + "its loop-head sample runs one iteration ahead of interp; skipped the whole {}-entry loop run and " + "converged at the exit. Continuing the hunt.", + ar.pc, loop_lo, loop_hi, loop_target, rs.blind)); + ++selfloop_skipped; + k = rs.k; + ii = rs.ii; + prev_pc = rs.prev_pc; + continue; + } + // Structural self-loop but the exit did NOT converge — not a mere + // phase offset. Fall through to the snapshot + real-divergence report. + } + } + + // EXCEPTION/INTERRUPT-HANDLER PHASE — checked CHEAPLY (stream-only, no + // snapshot) before the expensive data path. The divergence is entered inside + // the EE kernel exception handler (kseg0, pc >= 0x8000_0000): the JIT and + // interp took the same vblank/timer interrupt a few cycles apart in an idle + // poll loop, so EPC differs by an instruction and that value propagates into + // whatever scratch GPRs the handler touches (k0→t0→…). The handler dispatches + // on Cause (not EPC), saves+restores the FULL user register context, and + // ERETs to EPC — so on return to user code every user GPR is restored + // IDENTICAL and only EPC (excluded from the fingerprint) differs. ResyncAfter + // therefore reconverges the instant the handler returns. Gating on + // reconvergence is the safety net: a genuine kernel-codegen bug changes the + // post-return state and will NOT reconverge, falling through to the report. + if ((ar.pc >= 0x80000000u || ar.prev_pc >= 0x80000000u) && interrupt_skipped < kInterruptCap) + { + const ResyncResult rs = ResyncAfter(interp_fine, jit_fine, ar.jit_idx, ar.interp_idx); + if (rs.reconverged) + { + ++interrupt_skipped; + k = rs.k; + ii = rs.ii; + prev_pc = rs.prev_pc; + continue; + } + // Did not reconverge within the frame — not a benign handler phase. + // Fall through to the snapshot + real-divergence report. + } + + // DATA divergence — snapshot both streams at the entry (per-stream indices: + // interp dense vs JIT sparse) and classify. + if (!reload(false)) + return true; + const auto isnap = RunFineSnapAtFromHere(ar.interp_idx); + if (!reload(true)) + return true; + const auto jsnap = RunFineSnapAtFromHere(ar.jit_idx); + + std::vector divergent_gprs; + for (int i = 0; i < 32; ++i) + if (jsnap.cpu.GPR.r[i].UD[0] != isnap.cpu.GPR.r[i].UD[0] || + jsnap.cpu.GPR.r[i].UD[1] != isnap.cpu.GPR.r[i].UD[1]) + divergent_gprs.push_back(i); + + std::vector classified; + const auto timer_notes = ClassifyCycleDerivedLoads(ar.prev_pc, divergent_gprs, &classified); + + const bool fully_benign = + !divergent_gprs.empty() && + classified.size() == divergent_gprs.size() && + !NonGprDiffers(jsnap, isnap); + + if (fully_benign && timer_skipped < kTimerCap) + { + const ResyncResult rs = ResyncAfter(interp_fine, jit_fine, ar.jit_idx, ar.interp_idx); + if (rs.ran_off) + { + Console.WriteLn(fmt::format( + "STEPDIFF zoom: benign cycle-derived timer divergence entering pc={:#010x} ({}), but control " + "flow then perturbed within the blind window ({} entries) — the timer value propagated into a " + "branch. Can't cleanly look past it here; re-run from a later savestate.", + ar.pc, timer_notes.front(), rs.blind)); + if (ar.prev_pc) + DisasmBlock(ar.prev_pc); + return true; + } + if (!rs.reconverged) + { + Console.WriteLn(fmt::format( + "STEPDIFF zoom: benign cycle-derived timer divergence entering pc={:#010x} ({}); state never " + "re-converged before end-of-frame ({} entries stayed tainted). The timing difference persisted " + "— re-run from a later savestate to look past it.", + ar.pc, timer_notes.front(), rs.blind)); + return true; + } + Console.WriteLn(fmt::format( + "STEPDIFF zoom: skipping benign timer divergence entering pc={:#010x} (offending block {:#010x}; {}). " + "Resynced after a {}-entry blind window; continuing the hunt.", + ar.pc, ar.prev_pc, timer_notes.front(), rs.blind)); + ++timer_skipped; + k = rs.k; + ii = rs.ii; + prev_pc = rs.prev_pc; + continue; + } + + // REAL divergence (or benign-cap reached): full report + stop. + Console.WriteLn(fmt::format( + "STEPDIFF zoom: DATA divergence observed entering block pc={:#010x} (JIT block-entry #{}). " + "Offending JIT block: entry pc={:#010x} — its body produced register state differing from interp.", + ar.pc, ar.jit_idx, ar.prev_pc)); + if (ar.prev_pc) + DisasmBlock(ar.prev_pc); + + const auto diffs = DiffFullSnaps(jsnap, isnap); + if (diffs.empty()) + { + Console.WriteLn("STEPDIFF zoom: (detail re-run did not reproduce the field diff at the entry — the " + "divergence may be mid-block / in memory; the block disasm above is the lead.)"); + } + else + { + Console.WriteLn(fmt::format("STEPDIFF zoom: divergent register field(s) entering block pc={:#010x}:", ar.pc)); + for (const auto& d : diffs) + Console.WriteLn(fmt::format(" {}", d)); + if (!timer_notes.empty()) + { + Console.WriteLn("STEPDIFF zoom: SUSPECTED TIMING (some divergent GPRs are cycle-derived timer reads, " + "but NOT all divergent state is — see above):"); + for (const auto& n : timer_notes) + Console.WriteLn(fmt::format(" {}", n)); + } + } + if (timer_skipped >= kTimerCap) + Console.WriteLn(fmt::format( + "STEPDIFF zoom: NOTE — hit the timer-skip cap ({}); this report may itself be another timer " + "artifact. Re-run from a later savestate if so.", kTimerCap)); + else if (timer_skipped + selfloop_skipped > 0) + Console.WriteLn(fmt::format( + "STEPDIFF zoom: (walked past {} benign divergence(s) — {} timer, {} self-loop phase — before " + "reaching this one.)", timer_skipped + selfloop_skipped, timer_skipped, selfloop_skipped)); + Console.WriteLn(fmt::format( + "STEPDIFF zoom: offending block pc={:#010x} — capture a single-block EE fixture there to pin the opcode.", + ar.prev_pc)); + return true; + } +} + + +// =========================================================================== +// --stepdiff : checkpoint-anchored per-frame interp-vs-JIT comparison. +// +// The frame-boundary funnel (--localize) runs interp and JIT as two separate +// full passes and diffs them at frame boundaries. That conflates two things: +// real codegen divergence, and the ~10-cycle async sampling jitter at the +// frame-advance pause point (proven by --selfcheck: two warm interp runs +// already disagree at the boundary, yet committed memory re-captures identical). +// Accumulated over a pass, that jitter looks exactly like a JIT bug — a +// diagnostic tarpit. +// +// --stepdiff removes the accumulation: at each frame it CHECKPOINTS the VM +// (in-flight savestate), then runs ONE frame three times from that IDENTICAL +// state — interp twice (a determinism control) and JIT once. From a shared +// checkpoint: +// * interp-vs-interp divergence => async sampling jitter (this frame is noisy) +// * interp-vs-interp clean + interp-vs-JIT divergence => a REAL EE JIT bug +// The golden timeline is advanced one interp frame between checkpoints, so the +// scan walks the whole run while every comparison starts from a clean state. +// =========================================================================== +// --contmem : continuous-trajectory memory diff. The checkpoint-anchored +// --stepdiff re-anchors to the golden interp state every frame, so it can NEVER +// reproduce an ACCUMULATION bug (drift that builds over ~1s of CONTINUOUS JIT — +// the Burnout 3 physics-explosion shape); it also fights LoadState pause-point +// jitter. This instead runs interp CONTINUOUSLY for the whole window +// (deterministic, per --selfcheck) twice as a control + JIT CONTINUOUSLY once, +// then diffs the per-frame memory-hash trajectories. The first frame whose +// hashes differ WITH a clean interp control is where continuous JIT first +// deviates; on it the EE-RAM region is localized via a byte diff. Cross-arch: +// run the SAME invocation on x86 — if x86 also diverges early the divergence is +// shared benign timing, if x86 stays clean it's an arm64-specific EE-JIT bug. +// `--vu0-interp` forces VU0=interp in every pass so the only cross-pass +// difference stays the EE engine (isolates EE-COP1-FPU/integer from VU0/COP2). +static int RunContinuousMemTrajectory() +{ + Error error; + const bool force_vu0_interp = s_contmem_vu0_interp; + auto runPass = [&](bool jit, std::vector* cycles = nullptr) -> std::vector { + std::vector hashes; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("contmem: load failed: {}", error.GetDescription()); + return hashes; + } + SetEeMode(jit); + if (force_vu0_interp) + { + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableVU0", false); + VMManager::ApplySettings(); + } + hashes.reserve(s_frames); + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + VMManager::FrameAdvance(1); + VMManager::Execute(); + hashes.push_back(ee_divtrace::HashMemory()); + if (cycles) + cycles->push_back(static_cast(cpuRegs.cycle)); + } + return hashes; + }; + + // Cycle-drift trajectory: EE cpuRegs.cycle at each frame boundary, JIT vs interp. + // This is DETERMINISTIC (unlike the chaotic memory diff): if the JIT's per-block + // emitted cycle cost matches the interpreter's, the two cycle counts stay locked; + // a growing |jit.cycle - interp.cycle| is the EE-JIT cycle-accounting drift that + // shifts every cycle-clocked subsystem (DMA/VIF/timers) out of phase. Cross-arch: + // if arm64's per-frame drift >> x86's, arm64 EE block-cycle costs are the bug. + std::vector ic, jc; + Console.WriteLn("CONTMEM: interp pass 1 (continuous)..."); + const auto i1 = runPass(false, &ic); + Console.WriteLn("CONTMEM: interp pass 2 (continuous, determinism control)..."); + const auto i2 = runPass(false); + Console.WriteLn("CONTMEM: JIT pass (continuous)..."); + const auto j = runPass(true, &jc); + + // Cycle-drift report (deterministic; compare arm64's vs x86's numbers cross-arch). + { + const size_t cn = std::min(ic.size(), jc.size()); + int64_t maxabs = 0; + size_t maxf = 0; + for (size_t f = 0; f < cn; ++f) + { + const int64_t d = (int64_t)jc[f] - (int64_t)ic[f]; + if (std::llabs(d) > std::llabs(maxabs)) { maxabs = d; maxf = f; } + } + Console.WriteLn("CONTMEM CYCLE-DRIFT (EE cpuRegs.cycle, jit - interp, per frame):"); + for (size_t f = 0; f < cn; ++f) + { + const int64_t d = (int64_t)jc[f] - (int64_t)ic[f]; + if (f < 12 || f + 4 >= cn || std::llabs(d) == std::llabs(maxabs)) + Console.WriteLn(fmt::format(" frame {:3}: interp.cycle={} jit.cycle={} drift={:+d}", f, ic[f], jc[f], d)); + } + Console.WriteLn(fmt::format("CONTMEM CYCLE-DRIFT SUMMARY: max |drift| = {:+d} EE cycles at frame {} (of {} frames).", + maxabs, maxf, cn)); + } + + const size_t n = std::min({i1.size(), i2.size(), j.size()}); + int first_ctrl = -1, first_real = -1; + for (size_t f = 0; f < n; ++f) + { + const bool ctrl_div = i1[f] != i2[f]; + const bool jit_div = i1[f] != j[f]; + if (ctrl_div && first_ctrl < 0) + first_ctrl = (int)f; + if (jit_div && !ctrl_div && first_real < 0) + first_real = (int)f; + Console.WriteLn(fmt::format("CONTMEM frame {:3}: interp1={:#018x} interp2={:#018x} jit={:#018x} ctrl={} jit-vs-interp={}", + f, i1[f], i2[f], j[f], ctrl_div ? "DIFF" : "ok", jit_div ? "DIFF" : "ok")); + } + Console.WriteLn(fmt::format( + "CONTMEM SUMMARY: {} frames; interp determinism first breaks at frame {} ; " + "continuous JIT-vs-interp memory first diverges (with clean interp control) at frame {}.", + n, first_ctrl, first_real)); + // Capture EE main RAM + scratchpad after running `frame+1` continuous frames + // in the given mode (honors --vu0-interp), for an interp-vs-JIT byte-region diff. + auto capMem = [&](bool jit, uint32_t frame) -> std::vector { + std::vector out; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) return out; + SetEeMode(jit); + if (force_vu0_interp) + { + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableVU0", false); + VMManager::ApplySettings(); + } + AdvanceFrames(frame + 1); + out.resize(Ps2MemSize::MainRam + Ps2MemSize::Scratch); + std::memcpy(out.data(), eeMem->Main, Ps2MemSize::MainRam); + std::memcpy(out.data() + Ps2MemSize::MainRam, eeMem->Scratch, Ps2MemSize::Scratch); + return out; + }; + + if (first_real >= 0) + { + Console.WriteLn(fmt::format("CONTMEM: EE-RAM region @ FIRST clean-control JIT divergence (frame {}) — divergence onset:", first_real)); + ReportMemDiff(capMem(false, (uint32_t)first_real), capMem(true, (uint32_t)first_real)); + } + // Magnitude trajectory: diff again at the LAST frame. A BOUNDED drift stays a + // similar page/byte count to the onset frame (→ the divergence is benign timing + // phase, the explosion is elsewhere); an EXPLOSION balloons to thousands of + // pages of NaN/garbage physics (→ this divergence IS the corruption). Interp's + // own late-frame nondeterminism adds only a small page-count floor, far below an + // explosion's footprint. + if (n >= 2) + { + const uint32_t lastf = (uint32_t)n - 1; + Console.WriteLn(fmt::format("CONTMEM: interp-vs-interp CONTROL region @ LAST frame ({}) — nondeterminism noise floor:", lastf)); + ReportMemDiff(capMem(false, lastf), capMem(false, lastf)); + Console.WriteLn(fmt::format("CONTMEM: EE-RAM region @ LAST frame ({}) — JIT-vs-interp; subtract the control floor above:", lastf)); + ReportMemDiff(capMem(false, lastf), capMem(true, lastf)); + } + + // --memdump: write the raw EE main RAM + scratchpad of the interp and jit passes + // at the LAST frame, for a cross-machine JIT-vs-JIT diff. Interp is bit-identical + // cross-arch (deterministic IEEE) and EE cycles are locked, so diffing arm64's + // .jit.bin vs x86's .jit.bin isolates the arm64-specific COMPUTATIONAL EE-JIT + // divergence directly. Use a SMALL --frames (e.g. 2-3) so chaotic amplification + // hasn't spread the seed yet. The .interp.bin pair should be byte-identical + // cross-arch (a sanity check on cross-arch determinism). + if (!s_memdump_prefix.empty() && n >= 1) + { + const uint32_t lastf = (uint32_t)n - 1; + auto dump = [&](bool jit, const char* tag) { + const std::vector m = capMem(jit, lastf); + const std::string path = fmt::format("{}.{}.bin", s_memdump_prefix, tag); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) { Console.ErrorFmt("CONTMEM: failed to open {}", path); return; } + f.write(reinterpret_cast(m.data()), static_cast(m.size())); + Console.WriteLn(fmt::format("CONTMEM: wrote {} ({} bytes, frame {}).", path, m.size(), lastf)); + }; + dump(false, "interp"); + dump(true, "jit"); + } + return EXIT_SUCCESS; +} + +// --speedhack-diff : speedhack-misfire differential. +// +// Speedhacks are silent, runtime-gated divergences: each one CLAIMS to skip only +// dead work (a spin loop, an intc_stat poll, a redundant flag update) and leave +// the EE/VU architectural result unchanged. When that claim is wrong — as the EE +// recompiler's WaitLoop timeout-loop skip was for Burnout 3's DMA-display-list +// build loop — the game corrupts state and hangs, with EE=interp clean and +// EE=jit broken. No existing state-diff tool catches this because they compare +// jit-vs-interp at ONE speedhack config; the bug lived in a config axis no test +// varied. +// +// This mode varies that axis. It runs the savestate forward in EE-jit throughout +// (the speedhacks are jit-gated), establishes a baseline with every +// transparency-class speedhack OFF (run twice, for the run-to-run determinism +// floor), then sweeps each speedhack on its own and all-on. A transparent +// speedhack must NOT change the per-frame EE-RAM hash before the baseline control +// floor breaks; the FIRST such clean-control divergence is a misfire, and +// ReportMemDiff at that frame localizes the corrupted region (e.g. the GIF DMA +// chain). No core instrumentation — reuses HashMemory/ReportMemDiff. This is the +// system-level positive-side coverage the unit tests can't give (a fired skip +// diverges from naive interp BY DESIGN; only equality-against-an-honest-baseline +// validates it). +// +// Excluded by construction: vuThread/MTVU (nondeterministic — thread races defeat +// an equality diff) and EECycleRate/EECycleSkip (deliberately lossy cycle scaling +// — they change results on purpose). Diffs EE main RAM + scratchpad (where +// DMA-chain / display-list corruption lands), the same surface as --contmem. +static int RunSpeedhackDiff() +{ + Error error; + + // The swept speedhacks. equalityClass = CLAIMS bit-exact equivalence (skips + // provably-dead work); a sustained divergence from those is off-spec. The + // others are deliberate timing approximations that legitimately change a few + // async-phase bytes — only a runaway EXPLOSION flags them. Bit i = knob i ON. + struct Knob { const char* name; const char* key; bool equalityClass; }; + static const Knob kKnobs[] = { + {"WaitLoop", "WaitLoop", true}, // EE timeout/idle-loop skip (recSkipTimeoutLoop) — the Burnout-3 culprit + {"IntcStat", "IntcStat", true}, // fast-forward through intc_stat poll waits (skip-to-event) + {"vuFlagHack", "vuFlagHack", true}, // microVU status/mac flag elision (redundant-write skip) + {"vu1Instant", "vu1Instant", false}, // run VU1 to completion instantly — lossy timing approximation + {"fastCDVD", "fastCDVD", false}, // shorten CDVD access latency — lossy timing approximation + }; + constexpr size_t kNumKnobs = std::size(kKnobs); + const uint32_t kAllOn = (1u << kNumKnobs) - 1u; + + auto maskLabel = [&](uint32_t mask) -> std::string { + if (mask == 0) + return "baseline(all-off)"; + if (mask == kAllOn) + return "all-on"; + std::string s; + for (size_t k = 0; k < kNumKnobs; ++k) + if ((mask >> k) & 1u) + s += (s.empty() ? "" : "+") + std::string(kKnobs[k].name); + return s; + }; + + auto applyConfig = [&](uint32_t mask) { + for (size_t k = 0; k < kNumKnobs; ++k) + s_settings_interface.SetBoolValue("EmuCore/Speedhacks", kKnobs[k].key, ((mask >> k) & 1u) != 0u); + // Force the excluded knobs to their neutral / off state so they never + // contaminate the differential. + s_settings_interface.SetBoolValue("EmuCore/Speedhacks", "vuThread", false); + s_settings_interface.SetIntValue("EmuCore/Speedhacks", "EECycleRate", 0); + s_settings_interface.SetIntValue("EmuCore/Speedhacks", "EECycleSkip", 0); + // Speedhacks bite the EE recompiler; run it (not interp) in every pass. + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", true); + VMManager::ApplySettings(); + }; + + auto runPass = [&](uint32_t mask, std::vector* cyc = nullptr) -> std::vector { + std::vector hashes; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("speedhack-diff: load failed: {}", error.GetDescription()); + return hashes; + } + applyConfig(mask); + hashes.reserve(s_frames); + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + VMManager::FrameAdvance(1); + VMManager::Execute(); + hashes.push_back(ee_divtrace::HashMemory()); + if (cyc) + cyc->push_back(static_cast(cpuRegs.cycle)); + } + return hashes; + }; + + auto capMem = [&](uint32_t mask, uint32_t frame) -> std::vector { + std::vector out; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + return out; + applyConfig(mask); + AdvanceFrames(frame + 1); + out.resize(Ps2MemSize::MainRam + Ps2MemSize::Scratch); + std::memcpy(out.data(), eeMem->Main, Ps2MemSize::MainRam); + std::memcpy(out.data() + Ps2MemSize::MainRam, eeMem->Scratch, Ps2MemSize::Scratch); + return out; + }; + + // Baseline determinism sanity: run all-off twice and report how long the + // per-frame hash stays bit-identical. If this breaks immediately the + // savestate/renderer setup is nondeterministic and the verdicts below are + // unreliable (reduce --frames or check the setup). + Console.WriteLn("SPEEDHACK-DIFF: baseline determinism pass A (all speedhacks OFF)..."); + const auto baseA = runPass(0); + Console.WriteLn("SPEEDHACK-DIFF: baseline determinism pass B (all speedhacks OFF)..."); + const auto baseB = runPass(0); + if (baseA.empty() || baseB.empty()) + return EXIT_FAILURE; + { + const size_t bn = std::min(baseA.size(), baseB.size()); + int floorFrame = -1; + for (size_t f = 0; f < bn; ++f) + if (baseA[f] != baseB[f]) { floorFrame = static_cast(f); break; } + Console.WriteLn(fmt::format( + "SPEEDHACK-DIFF: baseline (all-off) stays run-to-run bit-identical through frame {} (of {}).", + floorFrame < 0 ? static_cast(bn) : floorFrame, bn)); + } + + // The verdict is NOT "any byte differs". Two distinct speedhack classes exist: + // - equality-class (WaitLoop/IntcStat/vuFlagHack): skip provably-dead work + // (a spin loop to its next event, a redundant flag) and CLAIM bit-exact + // equivalence. Any sustained divergence from all-off is suspect. + // - lossy-timing (vu1Instant/fastCDVD): deliberate approximations that DO + // change cycle timing (and therefore a few async-phase bytes) on purpose. + // Only a runaway EXPLOSION matters for these. + // The clean cross-class discriminator for the corruption/hang family (the + // Burnout-3 WaitLoop misfire) is GROWTH: corruption balloons across the + // window; a legit timing offset stays bounded. So we sample the EE-RAM + // divergence magnitude at several frames and compare its growth + absolute + // size against the determinism floor (two independent all-off runs). Same + // bounded-vs-explosion logic --contmem uses, swept over the speedhack axis. + std::vector samples; + { + const uint32_t N = s_frames; + auto add = [&](uint32_t f) { + if (f < N && std::find(samples.begin(), samples.end(), f) == samples.end()) + samples.push_back(f); + }; + add(N >= 8 ? N / 4 : 0); + add(N / 2); + add((3u * N) / 4u); + if (N >= 1) + add(N - 1); + std::sort(samples.begin(), samples.end()); + } + + // Reference (all-off run #1) and an independent all-off run #2 for the floor, + // captured once per sample frame and reused across every config. + std::vector> baseCaps, floorCaps; + std::vector floorTraj; + for (uint32_t sf : samples) + { + baseCaps.push_back(capMem(0, sf)); + floorCaps.push_back(capMem(0, sf)); + floorTraj.push_back(ReportMemDiff(baseCaps.back(), floorCaps.back(), /*verbose=*/false)); + } + { + std::string s; + for (size_t i = 0; i < samples.size(); ++i) + s += fmt::format("{}f={}p ", samples[i], floorTraj[i].pages); + Console.WriteLn("SPEEDHACK-DIFF: determinism floor trajectory (all-off vs all-off): " + s); + } + const MemDiffCount floorLast = floorTraj.empty() ? MemDiffCount{} : floorTraj.back(); + + // Is every speedhack in `mask` an equality-class one (and mask non-empty)? + auto pureEqualityClass = [&](uint32_t mask) -> bool { + if (mask == 0) + return false; + for (size_t k = 0; k < kNumKnobs; ++k) + if (((mask >> k) & 1u) && !kKnobs[k].equalityClass) + return false; + return true; + }; + + struct Verdict { uint32_t mask; std::vector traj; bool explosion; }; + std::vector verdicts; + + auto evalConfig = [&](uint32_t mask) { + std::vector traj; + for (size_t i = 0; i < samples.size(); ++i) + traj.push_back(ReportMemDiff(baseCaps[i], capMem(mask, samples[i]), /*verbose=*/false)); + const MemDiffCount first = traj.front(); + const MemDiffCount last = traj.back(); + + // EXPLOSION = the corruption/hang signature: a page spread well past the + // determinism floor that GREW across the window (a misfire's corruption + // onsets mid-run and runs away, e.g. buggy WaitLoop 14p->10p->10p->206p). + // This is class-agnostic and the ONLY automated verdict — every swept + // speedhack perturbs SOME bounded state by design (a correct WaitLoop/IntcStat + // leaves the abandoned spin counter + a few async-phase bytes; + // vu1Instant/fastCDVD shift timing), so "diverges at all" is not a bug; only + // runaway GROWTH is. + // + // Absolute magnitude alone cannot discriminate: chaos-amplifying 3D titles + // (e.g. GTA San Andreas) amplify a 110-byte sub-ULP seed (the unavoidable + // arm64 fmadd/-ffp-contract JIT-vs-interp difference) into a 576p intrinsic + // chaos floor with NO speedhack at all (see --contmem); a 303p WaitLoop + // divergence there is *below* that floor, same scattered-1-ULP-FP character, + // and is NOT a misfire. Magnitude (absolute OR relative-to-floor) can't + // separate that from a real misfire (Burnout-3 buggy 206p was only 1.2x its + // 166p fixed floor); the late-onset GROWTH shape can: + // GTA's flat-high 282->303 fails `grew`, Burnout's 14->206 passes it. + // Empirically validated: Burnout-3 buggy WaitLoop flagged, fixed not; + // GTA SA / R&C UYA WaitLoop+IntcStat correctly NOT flagged. + const size_t floorPad = floorLast.pages * 4 + 16; + const bool grew = last.pages >= first.pages * 4; + const bool explosion = last.pages >= 32 && last.pages > floorPad && grew; + + std::string trajStr; + for (size_t i = 0; i < samples.size(); ++i) + trajStr += fmt::format("{}f={}p/{}b ", samples[i], traj[i].pages, traj[i].bytes); + // Only an EQUALITY-class config (WaitLoop/IntcStat/vuFlagHack — claims + // bit-exactness) exploding is an actionable misfire. Lossy-class configs + // (vu1Instant/fastCDVD/all-on) are DESIGNED to diverge — fastCDVD shortens + // disc latency, so on a slot that's actively streaming it legitimately loads + // MBs of assets earlier (seen as a multi-hundred-page explosion). That is + // expected, not a bug, so it must not read as "investigate as misfire". + const bool isEquality = (mask != 0) && pureEqualityClass(mask); + const char* cls = (mask == 0) ? "-" : (isEquality ? "equality" : "lossy/mixed"); + const char* verdictStr = !explosion ? "bounded (expected)" + : (isEquality ? "EXPLOSION (likely misfire/corruption)" + : "EXPLOSION (expected — lossy timing class, not a misfire)"); + Console.WriteLn(fmt::format("SPEEDHACK-DIFF [{}] class={}: {}", maskLabel(mask), cls, verdictStr)); + Console.WriteLn(" trajectory (baseline-all-off vs config): " + trajStr + + fmt::format("(floor_last={}p)", floorLast.pages)); + if (explosion) + { + Console.WriteLn(fmt::format(" EE-RAM region @ frame {} — baseline(all-off) vs [{}]:", + samples.back(), maskLabel(mask))); + ReportMemDiff(baseCaps.back(), capMem(mask, samples.back()), /*verbose=*/true); + } + verdicts.push_back({mask, std::move(traj), explosion}); + }; + + // Sweep each speedhack on its own, then all-on (interaction check). + for (size_t k = 0; k < kNumKnobs; ++k) + evalConfig(1u << k); + evalConfig(kAllOn); + + // Summary. + Console.WriteLn(fmt::format("SPEEDHACK-DIFF SUMMARY (floor_last = {} pages / {} bytes @ frame {}):", + floorLast.pages, floorLast.bytes, samples.empty() ? 0u : samples.back())); + int misfires = 0, lossyExplosions = 0; + for (const auto& v : verdicts) + { + const bool isEquality = (v.mask != 0) && pureEqualityClass(v.mask); + const bool misfire = v.explosion && isEquality; + misfires += misfire ? 1 : 0; + lossyExplosions += (v.explosion && !isEquality) ? 1 : 0; + const MemDiffCount last = v.traj.empty() ? MemDiffCount{} : v.traj.back(); + const char* tag = !v.explosion ? "bounded" + : (misfire ? "EXPLOSION <-- likely misfire" : "EXPLOSION (expected lossy)"); + Console.WriteLn(fmt::format(" {:<28} last {:>6} pages / {:>8} bytes {}", + maskLabel(v.mask), last.pages, last.bytes, tag)); + } + Console.WriteLn(fmt::format( + "SPEEDHACK-DIFF: {} equality-class MISFIRE(s) — the actionable signal — plus {} expected lossy-class explosion(s).", + misfires, lossyExplosions)); + return EXIT_SUCCESS; +} + +static int RunStepDiff() +{ + Error error; + const std::string ckpt = Path::Combine(EmuFolders::Cache, "eerunner_stepdiff.p2s"); + + auto saveCkpt = [&]() -> bool { + bool ok = true; + VMManager::SaveState(ckpt.c_str(), /*zip_on_thread=*/false, /*backup_old_state=*/false, + [&](const std::string& e) { ok = false; Console.ErrorFmt("stepdiff: save failed: {}", e); }); + VMManager::WaitForSaveStateFlush(); + return ok; + }; + auto loadCkpt = [&]() -> bool { + if (!VMManager::LoadState(ckpt.c_str(), &error)) + { + Console.ErrorFmt("stepdiff: load checkpoint failed: {}", error.GetDescription()); + return false; + } + return true; + }; + // Run one frame in the given mode from the just-loaded checkpoint, returning + // the end-of-frame full snapshot + memory hash. + auto runOne = [&](bool jit, ee_divtrace::FullSnap& snap, uint64_t& memhash) -> bool { + if (!loadCkpt()) + return false; + SetEeMode(jit); + AdvanceFrames(1); + snap = CaptureFullSnap(); + memhash = ee_divtrace::HashMemory(); + return true; + }; + + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("stepdiff: initial load failed: {}", error.GetDescription()); + return EXIT_FAILURE; + } + SetEeMode(false); // golden timeline is interp + + int benign_frames = 0; + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + if (!saveCkpt()) + return EXIT_FAILURE; + + ee_divtrace::FullSnap i1, i2, j; + uint64_t i1m = 0, i2m = 0, jm = 0; + if (!runOne(false, i1, i1m) || !runOne(false, i2, i2m) || !runOne(true, j, jm)) + return EXIT_FAILURE; + + const auto ii = DiffFullSnaps(i1, i2); + const bool ii_clean = ii.empty() && i1m == i2m && i1.pc == i2.pc; + const auto ij = DiffFullSnaps(j, i1); // labels: JIT=j, INTERP=i1 + const bool ij_diverged = !ij.empty() || (i1m != jm) || (i1.pc != j.pc); + + if (ij_diverged) + { + Console.WriteLn(fmt::format( + "STEPDIFF frame {}: interp-vs-JIT DIVERGES (pc interp={:#010x} jit={:#010x}, mem {}); " + "interp-vs-interp control = {}", + f, i1.pc, j.pc, (i1m != jm) ? "DIFFERS" : "same", + ii_clean ? "CLEAN" : "ALSO DIVERGES (async jitter)")); + for (const auto& d : ij) + Console.WriteLn(fmt::format(" {}", d)); + if (ii_clean && i1m == jm) + { + // End-of-frame MEMORY identical — only live registers / pc differ. + // The EE is parked in a producer/consumer spin-wait (the GIF double- + // buffer poll at 0x1f24e0) and the two cores are sampled at different + // iteration counts at the vsync boundary. No architectural state + // PERSISTED differently, so this CANNOT be the divergence we hunt: a + // real EE-JIT computational bug stores its wrong value, which would + // show as a memory difference. Skip the zoom (it would only re-find + // the benign spin phase) and keep scanning. + Console.WriteLn(fmt::format( + " => frame {}: end-of-frame MEMORY identical (only live regs/pc differ) — benign spin-wait " + "phase at the vsync boundary; continuing scan.", f)); + ++benign_frames; + } + else if (ii_clean) + { + // Candidate real divergence (clean interp control, MEMORY differs). + // Zoom in INLINE to classify: the checkpoint still holds this frame's + // start (the zoom only LoadState-reads it). If the zoom resolves it + // all to benign cycle-derived timer reads / spin phase, keep scanning + // later frames; otherwise it's a real lead and we stop here. + Console.Error(" => candidate REAL EE JIT divergence (clean interp control, MEMORY differs) — zooming to classify..."); + const bool stop = ZoomFromCheckpoint(ckpt); + if (stop) + { + FileSystem::DeleteFilePath(ckpt.c_str()); + return EXIT_FAILURE; + } + ++benign_frames; + Console.WriteLn(fmt::format( + " => frame {}: the frame-boundary divergence resolved to benign timing only; continuing scan.", f)); + } + else + { + Console.WriteLn(" => discounted: interp control also diverges, so this is sampling jitter, " + "not a codegen bug."); + for (const auto& d : ii) + Console.WriteLn(fmt::format(" [ctrl] {}", d)); + } + } + + // Advance the golden interp timeline by one frame for the next checkpoint. + // (After an inline zoom the VM is wherever the last re-run left it; loadCkpt + // restores this frame's start, then AdvanceFrames steps to the next.) + if (!loadCkpt()) + return EXIT_FAILURE; + SetEeMode(false); + AdvanceFrames(1); + } + + FileSystem::DeleteFilePath(ckpt.c_str()); + if (benign_frames > 0) + Console.WriteLn(fmt::format( + "STEPDIFF: no real JIT divergence over {} frames ({} frame(s) had benign cycle-derived timer " + "divergences that the zoom walked past).", s_frames, benign_frames)); + else + Console.WriteLn(fmt::format("STEPDIFF: no real JIT divergence over {} frames (interp control clean throughout).", + s_frames)); + return EXIT_SUCCESS; +} + +// =================================================================== +// --vu0diff : live VU0-jit-vs-interp COP2-read value diff +// =================================================================== +// Pin the EE INTERPRETER in both passes and toggle ONLY the VU0 micro engine. +// Each pass loads the same checkpoint, runs one frame, and records every COP2 +// read (QMFC2 reads VF[fs], CFC2 reads VI[fs]) the EE interpreter performs of +// VU0 state, in execution order. Diffing the two read-streams pins the FIRST +// VU0 program output the micro JIT computes differently from the VU0 +// interpreter — live, with the real EE<->VU0 interleave the offline +// capture-replay harness can't reproduce. +// +// CAVEAT (read first): this is SINGLE-ARCH jit-vs-interp. It is the right tool +// for an arithmetic VALUE bug (the JIT computes a wrong number), but a +// divergence in the flag / Q / cycle pipeline INSTANCE is usually +// shared-with-x86 and NOT arch-specific — the FMAND-flag and cycle-bubble red +// herrings of the Burnout 3 hunt all collapsed this way. Confirm any lead from +// here with an arm64-jit-vs-x86-jit diff before trusting it. + +// Capture hooks live in pcsx2/VU0.cpp; null in production. +typedef void (*Cop2ReadHook)(u32 ee_pc, u32 op, u32 fs, const u32* lanes); +typedef void (*Cop2StateHook)(u32 tpc, u32 q, u32 mac, u32 status, u32 clip); +extern Cop2ReadHook g_cop2ReadHook; +extern Cop2StateHook g_cop2StateHook; + +namespace +{ +struct Cop2Read +{ + u32 ee_pc, op, fs; + u32 lanes[4]; + u32 tpc, q, mac, status, clip; +}; +std::vector s_cop2_sink; + +void Cop2ReadCapture(u32 ee_pc, u32 op, u32 fs, const u32* lanes) +{ + Cop2Read r{}; + r.ee_pc = ee_pc; + r.op = op; + r.fs = fs; + // QMFC2 (op 0) reads a full 128-bit VF; CFC2 (op 1) reads one 32-bit VI. + r.lanes[0] = lanes[0]; + r.lanes[1] = (op == 0) ? lanes[1] : 0; + r.lanes[2] = (op == 0) ? lanes[2] : 0; + r.lanes[3] = (op == 0) ? lanes[3] : 0; + s_cop2_sink.push_back(r); +} + +void Cop2StateCapture(u32 tpc, u32 q, u32 mac, u32 status, u32 clip) +{ + if (s_cop2_sink.empty()) + return; + Cop2Read& r = s_cop2_sink.back(); + r.tpc = tpc; + r.q = q; + r.mac = mac; + r.status = status; + r.clip = clip; +} +} // namespace + +// Diff two per-COP2-read VU0-value streams (a=interp golden, b=jit candidate) and +// report the FIRST read whose VU0 value differs. This pins the exact COP2 read — and +// thus the VU0 program output — where the micro JIT first diverges from the interp, +// upstream of the geometry-buffer corruption. VI flag regs read by CFC2 (16/17/18/ +// 22/23/26) are stopping-point/flag noise (shared block-overshoot); they're shown but +// the FIRST-QMFC2-divergence line is the actionable signal. +static void ReportCop2ReadDiff(const std::vector& a, const std::vector& b) +{ + auto asf = [](u32 u) { float f; std::memcpy(&f, &u, 4); return f; }; + auto is_flag_vi = [](u32 fs) { + return fs == 16 || fs == 17 || fs == 18 || fs == 22 || fs == 23 || fs == 26; + }; + const size_t n = std::min(a.size(), b.size()); + Console.WriteLn(fmt::format(" COP2-read streams: interp={} reads, jit={} reads", a.size(), b.size())); + int shown = 0; + bool first_qmfc2 = false; + for (size_t i = 0; i < n; ++i) + { + const Cop2Read& x = a[i]; + const Cop2Read& y = b[i]; + if (x.op != y.op || x.fs != y.fs || x.ee_pc != y.ee_pc) + { + Console.WriteLn(fmt::format( + " read #{}: STRUCTURAL divergence interp(pc={:#010x} op={} fs={}) jit(pc={:#010x} op={} fs={})", + i, x.ee_pc, x.op, x.fs, y.ee_pc, y.op, y.fs)); + break; // control flow split — everything after is unaligned + } + const bool diff = x.lanes[0] != y.lanes[0] || x.lanes[1] != y.lanes[1] || + x.lanes[2] != y.lanes[2] || x.lanes[3] != y.lanes[3]; + if (!diff) + continue; + const char* tag = (x.op == 0) ? "QMFC2 VF" : (is_flag_vi(x.fs) ? "CFC2 VI(flag)" : "CFC2 VI"); + if (x.op == 0) + { + if (!first_qmfc2) + { + Console.WriteLn(fmt::format( + " >>> FIRST QMFC2 VF divergence at read #{} (pc={:#010x} VF{:02}):", i, x.ee_pc, x.fs)); + first_qmfc2 = true; + } + Console.WriteLn(fmt::format( + " read #{} {} {:02} pc={:#010x}\n" + " interp {:08x}_{:08x}_{:08x}_{:08x} ({:g} {:g} {:g} {:g})\n" + " jit {:08x}_{:08x}_{:08x}_{:08x} ({:g} {:g} {:g} {:g})", + i, tag, x.fs, x.ee_pc, + x.lanes[0], x.lanes[1], x.lanes[2], x.lanes[3], asf(x.lanes[0]), asf(x.lanes[1]), asf(x.lanes[2]), asf(x.lanes[3]), + y.lanes[0], y.lanes[1], y.lanes[2], y.lanes[3], asf(y.lanes[0]), asf(y.lanes[1]), asf(y.lanes[2]), asf(y.lanes[3]))); + } + else + { + Console.WriteLn(fmt::format( + " read #{} {} {:02} pc={:#010x} interp={:08x} jit={:08x}", + i, tag, x.fs, x.ee_pc, x.lanes[0], y.lanes[0])); + } + if (++shown >= 40) + { + Console.WriteLn(" ... (40 divergent reads shown; truncating)"); + break; + } + } + if (a.size() != b.size()) + Console.WriteLn(fmt::format(" NOTE: read-count differs (interp={} jit={}) — VU0-jit drove a different COP2 path", + a.size(), b.size())); +} + +static int RunVu0Diff() +{ + Error error; + const std::string ckpt = Path::Combine(EmuFolders::Cache, "eerunner_vu0diff.p2s"); + + auto saveCkpt = [&]() -> bool { + bool ok = true; + VMManager::SaveState(ckpt.c_str(), /*zip_on_thread=*/false, /*backup_old_state=*/false, + [&](const std::string& e) { ok = false; Console.ErrorFmt("vu0diff: save failed: {}", e); }); + VMManager::WaitForSaveStateFlush(); + return ok; + }; + // One pass: load the checkpoint, set VU0 mode, capture the COP2 read-stream + // for exactly one frame from the just-loaded checkpoint. + auto runOne = [&](bool vu0_jit, std::vector& out) -> bool { + if (!VMManager::LoadState(ckpt.c_str(), &error)) + { + Console.ErrorFmt("vu0diff: load checkpoint failed: {}", error.GetDescription()); + return false; + } + SetVu0Mode(vu0_jit); + s_cop2_sink.clear(); + g_cop2ReadHook = &Cop2ReadCapture; + g_cop2StateHook = &Cop2StateCapture; + AdvanceFrames(1); + g_cop2ReadHook = nullptr; + g_cop2StateHook = nullptr; + out = s_cop2_sink; + return true; + }; + + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("vu0diff: initial load failed: {}", error.GetDescription()); + return EXIT_FAILURE; + } + SetVu0Mode(false); // golden timeline = VU0 interp + + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + if (!saveCkpt()) + return EXIT_FAILURE; + + std::vector interp, jit; + if (!runOne(false, interp) || !runOne(true, jit)) + return EXIT_FAILURE; + + // Did any QMFC2 VF value diverge? (The actionable signal; flag-VI noise + // is reported by ReportCop2ReadDiff but doesn't gate the per-frame header.) + bool qmfc2_diverged = false; + const size_t n = std::min(interp.size(), jit.size()); + for (size_t i = 0; i < n && !qmfc2_diverged; ++i) + { + const Cop2Read& x = interp[i]; + const Cop2Read& y = jit[i]; + if (x.op == 0 && (x.lanes[0] != y.lanes[0] || x.lanes[1] != y.lanes[1] || + x.lanes[2] != y.lanes[2] || x.lanes[3] != y.lanes[3])) + qmfc2_diverged = true; + } + + Console.WriteLn(fmt::format("VU0DIFF frame {}: {} ({} interp / {} jit COP2 reads)", f, + qmfc2_diverged ? "QMFC2 VF DIVERGES" : "no QMFC2 value divergence", interp.size(), jit.size())); + if (qmfc2_diverged || interp.size() != jit.size()) + ReportCop2ReadDiff(interp, jit); + + // Advance the golden interp timeline by one frame for the next checkpoint. + if (!VMManager::LoadState(ckpt.c_str(), &error)) + return EXIT_FAILURE; + SetVu0Mode(false); + AdvanceFrames(1); + } + + FileSystem::DeleteFilePath(ckpt.c_str()); + Console.WriteLn(fmt::format("VU0DIFF: scanned {} frame(s).", s_frames)); + return EXIT_SUCCESS; +} + +// --liverun: reproduce the in-game HANG headlessly. Unlike the deterministic diff +// modes, this enables the live subsystems (real GS so GIF is consumed, MTVU on) and +// runs a single straight EE-jit pass. If the EE wedges in a spin/sync loop (the +// frozen-frame + looping-audio symptom), VMManager::Execute() never returns for a +// frame; a watchdog thread notices the stalled frame counter, samples the live EE +// PC (cpuRegs.pc) to fingerprint the spin loop, prints a PC histogram, and exits +// with code 42. A clean completion (all frames, code 0) means we did NOT reproduce +// the hang in this configuration. +// --disasm: load the savestate and disassemble EE code in [EERUNNER_DIS_LO, +// EERUNNER_DIS_HI] (default 0x100000..0x100040) with the correct R5900 disassembler. +// Generic MIPS disassemblers garble R5900 COP2/MMI ops; this tool does not. +static int RunDisasm() +{ + Error error; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("disasm: load failed: {}", error.GetDescription()); + return EXIT_FAILURE; + } + u32 lo = 0x100000, hi = 0x100040; + if (const char* e = std::getenv("EERUNNER_DIS_LO")) lo = (u32)strtoul(e, nullptr, 0); + if (const char* e = std::getenv("EERUNNER_DIS_HI")) hi = (u32)strtoul(e, nullptr, 0); + Console.WriteLn(fmt::format("DISASM 0x{:08x}..0x{:08x}:", lo, hi)); + for (u32 a = lo; a <= hi; a += 4) + { + const u32 code = memRead32(a); + std::string line; + R5900::disR5900Fasm(line, code, a, false); + Console.WriteLn(fmt::format(" {:#010x}: {:08x} {}", a, code, line)); + } + return EXIT_SUCCESS; +} + +static std::atomic s_liverun_frame{0}; +static std::atomic s_liverun_done{false}; + +// Step-0 wedge classifier: snapshot the EE event/interrupt scheduler state. The +// recompiler and interpreter SHARE all of _cpuEventTest_Shared / intc / dmac / +// scheduling — the only jit-specific variables are (a) when a block re-enters the +// event test and (b) the cpuRegs.cycle value it has accumulated. So a "this IRQ +// never fires under jit" wedge is one of three modes, distinguishable here: +// A cycle FROZEN between snapshots -> spin block costs 0 cycles / RECCYCLE stuck +// B cycle advances, INTC/DMAC pending+unmasked but never serviced -> arm64 codegen (event-test/x25) +// C cycle advances, nothing pending -> IRQ never scheduled (IOP/SIF/DMA upstream) +// Racy reads of globals from the watchdog thread — fine for a stuck loop. +static void DumpEeEventState(int snap) +{ + const u64 cyc = cpuRegs.cycle; + const u64 nextE = cpuRegs.nextEventCycle; + const u64 lastE = cpuRegs.lastEventCycle; + const u32 ints = cpuRegs.interrupt; + const u32 stat = cpuRegs.CP0.n.Status.val; + const u32 intcS = psHu32(INTC_STAT); + const u32 intcM = psHu32(INTC_MASK); + const u16 dmacS = psHu16(0xe012); + const u16 dmacM = psHu16(0xe010); + Console.Error(fmt::format( + " [snap {}] cycle={} nextEvent={} (dEvt={}) lastEvent={} | interrupt=0x{:08x} branch={}", + snap, cyc, nextE, (s64)(nextE - cyc), lastE, ints, cpuRegs.branch)); + Console.Error(fmt::format( + " CP0.Status=0x{:08x} (EIE={} ERL={} EXL={} IE={} IM_INTC={} IM_DMAC={})", + stat, (stat >> 16) & 1, (stat >> 2) & 1, (stat >> 1) & 1, stat & 1, + (stat >> 10) & 1, (stat >> 11) & 1)); + Console.Error(fmt::format( + " INTC_STAT=0x{:08x} INTC_MASK=0x{:08x} (pending&unmasked=0x{:08x}) | DMAC_STAT=0x{:04x} DMAC_MASK=0x{:04x} (pend=0x{:04x})", + intcS, intcM, intcS & intcM, dmacS, dmacM, (u16)(dmacS & dmacM))); + // Per-source scheduled EE interrupts: which channels have a deadline, and has cycle passed it? + if (ints) + { + std::string sched; + for (int n = 0; n < 32; n++) + { + if (!(ints & (1u << n))) + continue; + const u64 deadline = cpuRegs.sCycle[n] + cpuRegs.eCycle[n]; + sched += fmt::format(" int[{}]: sCycle={} eCycle={} deadline={} ({}); ", + n, cpuRegs.sCycle[n], cpuRegs.eCycle[n], deadline, + (s64)(deadline - cyc) <= 0 ? "DUE" : "future"); + } + Console.Error(fmt::format(" scheduled:{}", sched)); + } +} + +static int RunLiveRun() +{ + Error error; + if (!VMManager::LoadState(s_savestate_path.c_str(), &error)) + { + Console.ErrorFmt("liverun: load failed: {}", error.GetDescription()); + return EXIT_FAILURE; + } + + Console.WriteLn(fmt::format( + "LIVERUN: EE=jit, MTVU=on, real GS — running up to {} frames (10s no-progress watchdog)...", + s_frames)); + + std::thread watchdog([]() { + uint32_t last = 0; + int stalled = 0; + while (!s_liverun_done.load(std::memory_order_relaxed)) + { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (s_liverun_done.load(std::memory_order_relaxed)) + return; + const uint32_t cur = s_liverun_frame.load(std::memory_order_relaxed); + if (cur != last) + { + last = cur; + stalled = 0; + continue; + } + if (++stalled < 20) // 20 * 500ms = ~10s of no frame progress + continue; + + // Wedged: the EE has not finished a frame in ~10s. Sample the live EE PC + // to fingerprint the spin/sync loop (racy read of a global u32 — fine for + // a stuck loop whose PC sits in a tiny range). + std::map hist; + u32 pcmin = ~0u, pcmax = 0; + const int N = 4000; + for (int i = 0; i < N; i++) + { + const u32 pc = cpuRegs.pc; + hist[pc]++; + pcmin = std::min(pcmin, pc); + pcmax = std::max(pcmax, pc); + std::this_thread::sleep_for(std::chrono::microseconds(250)); + } + Console.Error(fmt::format( + "LIVERUN WEDGE: no frame completed past frame {} for ~10s. " + "EE spin PC range [0x{:08x} .. 0x{:08x}], {} distinct PCs over {} samples:", + last, pcmin, pcmax, hist.size(), N)); + std::vector> top(hist.begin(), hist.end()); + std::sort(top.begin(), top.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + for (size_t i = 0; i < top.size() && i < 16; i++) + Console.Error(fmt::format(" pc=0x{:08x} {:5d} ({:4.1f}%)", + top[i].first, top[i].second, 100.0 * top[i].second / N)); + // Disassemble the dominant spin block so the poll/branch + the awaited + // memory operand are visible. The EE is stuck looping in this one block, + // so its code bytes are stable to read from here. + if (!top.empty()) + DisasmBlock(top[0].first); + + // Step-0 wedge classifier: two snapshots of the EE event/interrupt + // scheduler ~200ms apart. Whether cpuRegs.cycle MOVES between them, and + // whether an INTC/DMAC source is pending-but-unserviced, classifies the + // wedge into mode A (cycle frozen), B (codegen: pending never delivered), + // or C (never scheduled — upstream). See DumpEeEventState(). + Console.Error("LIVERUN WEDGE: EE event/interrupt scheduler state:"); + DumpEeEventState(0); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + DumpEeEventState(1); + + std::fflush(stdout); + std::fflush(stderr); + std::_Exit(42); // distinct from EXIT_FAILURE; bypass GS teardown deliberately + } + }); + + // Soft-freeze probe: the frame-count watchdog can't see this hang, because the EE + // keeps ticking vblank so frames KEEP completing (frozen frames). Instead watch a + // guest EE-RAM word (EERUNNER_WATCH_ADDR) that should keep changing during normal + // play; if it stays constant for many frames WHILE the frame counter advances, the + // game is frozen even though the harness "completes" frames. For Burnout 3 the + // idle-loop consumer pointer is 0x4e2838 (waits to equal 0x4e283c == addr+4). + u32 watch_addr = 0; + if (const char* e = std::getenv("EERUNNER_WATCH_ADDR")) + watch_addr = static_cast(strtoul(e, nullptr, 0)); + auto eeR32 = [](u32 paddr) -> u32 { + return *reinterpret_cast(reinterpret_cast(eeMem->Main) + (paddr & 0x1ffffffu)); + }; + u32 watch_prev = 0; + bool watch_init = false; + int watch_stuck = 0; + + for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f) + { + VMManager::FrameAdvance(1); + VMManager::Execute(); // blocks for one frame; if the EE wedges, never returns + s_liverun_frame.store(f + 1, std::memory_order_relaxed); + + if (watch_addr) + { + const u32 v = eeR32(watch_addr); + if (watch_init && v == watch_prev) + { + if (++watch_stuck == 120) // ~2s of frozen game while frames advanced + { + const u32 vb = eeR32(watch_addr + 4); + Console.Error(fmt::format( + "LIVERUN SOFT-FREEZE: watch[0x{:08x}]=0x{:08x} unchanged for {} frames " + "(frame counter reached {}); neighbor[+4]=0x{:08x}. Game frozen, vblank still ticking.", + watch_addr, v, watch_stuck, f + 1, vb)); + Console.Error("LIVERUN SOFT-FREEZE: EE event/interrupt scheduler state:"); + DumpEeEventState(0); + // Fingerprint the EE spin PCs (game idle loop). + std::map hist; + for (int i = 0; i < 2000; i++) + { + hist[cpuRegs.pc]++; + std::this_thread::sleep_for(std::chrono::microseconds(200)); + } + std::vector> top(hist.begin(), hist.end()); + std::sort(top.begin(), top.end(), [](auto& a, auto& b) { return a.second > b.second; }); + for (size_t i = 0; i < top.size() && i < 8; i++) + Console.Error(fmt::format(" pc=0x{:08x} {:5d}", top[i].first, top[i].second)); + // Disasm a window around the hottest spin PC — the loop the game + // is actually stuck in — so this is game-agnostic. Override the + // range with EERUNNER_DIS_LO..EERUNNER_DIS_HI when chasing a + // divergence whose deciding branch sits outside the spin window. + { + const u32 center = top.empty() ? cpuRegs.pc : top[0].first; + u32 lo = center - 0x40, hi = center + 0x40; + if (const char* e = std::getenv("EERUNNER_DIS_LO")) lo = (u32)strtoul(e, nullptr, 0); + if (const char* e = std::getenv("EERUNNER_DIS_HI")) hi = (u32)strtoul(e, nullptr, 0); + Console.Error(fmt::format("LIVERUN SOFT-FREEZE: disasm 0x{:08x}..0x{:08x}:", lo, hi)); + for (u32 a = lo; a <= hi; a += 4) + { + const u32 code = memRead32(a); + std::string line; + R5900::disR5900Fasm(line, code, a, false); + Console.Error(fmt::format(" {:#010x}: {:08x} {}", a, code, line)); + } + } + std::fflush(stdout); + std::fflush(stderr); + std::_Exit(43); // distinct from 42 (frame-count wedge) + } + } + else + { + watch_stuck = 0; + } + watch_prev = v; + watch_init = true; + } + } + + s_liverun_done.store(true, std::memory_order_relaxed); + watchdog.join(); + Console.WriteLn(fmt::format( + "LIVERUN: completed {} frames with NO wedge — this config did not reproduce the hang.", + s_frames)); + return EXIT_SUCCESS; +} + +#ifdef _WIN32 +// Unicode filenames require wmain on Win32; use the ascii main() with this workaround. +#define main real_main +#endif + +static void CPUThreadMain(VMBootParameters* params, std::atomic* ret) +{ + ret->store(EXIT_FAILURE); + + if (VMManager::Internal::CPUThreadInitialize()) + { + // apply new settings (e.g. pick up renderer change) + VMManager::ApplySettings(); + + if (VMManager::Initialize(*params) == VMBootResult::StartupSuccess) + { + // Run unlimited — the runner steps frame-by-frame and needs no + // wall-clock pacing. + VMManager::SetLimiterMode(LimiterModeType::Unlimited); + VMManager::SetState(VMState::Paused); + + int code = EXIT_FAILURE; + switch (s_mode) + { + case RunMode::SelfCheck: + code = RunSelfCheck(); + break; + + // --localize / --repro / --stepdiff all run the robust + // checkpoint-anchored comparison. The frame-boundary pass + // conflates codegen bugs with the ~10-cycle async pause-point + // sampling jitter (see --selfcheck, which characterizes that + // jitter). --repro is the fast iteration verb (point it at a + // savestate already narrowed to the bug); + // --localize/--stepdiff are aliases. + case RunMode::Localize: + case RunMode::Repro: + case RunMode::StepDiff: + code = RunStepDiff(); + break; + + case RunMode::Vu0Diff: + code = RunVu0Diff(); + break; + + case RunMode::ContMem: + code = RunContinuousMemTrajectory(); + break; + + case RunMode::SpeedhackDiff: + code = RunSpeedhackDiff(); + break; + + case RunMode::LiveRun: + code = RunLiveRun(); + break; + + case RunMode::Disasm: + code = RunDisasm(); + break; + + default: + break; + } + + VMManager::Shutdown(false); + ret->store(code); + } + else + { + Console.Error("eerunner: VMManager::Initialize failed."); + } + } + + VMManager::Internal::CPUThreadShutdown(); +} + +int main(int argc, char* argv[]) +{ + CrashHandler::Install(); + EERunner::InitializeConsole(); + + std::signal(SIGINT, [](int) { VMManager::SetState(VMState::Stopping); }); + std::signal(SIGTERM, [](int) { VMManager::SetState(VMState::Stopping); }); + + if (!EERunner::InitializeConfig()) + { + Console.Error("Failed to initialize config."); + return EXIT_FAILURE; + } + + VMBootParameters params; + if (!EERunner::ParseCommandLineArgs(argc, argv, params)) + return EXIT_FAILURE; + + SysMemory::ReserveMemory(); + + // --selfcheck and --vu0diff force the EE interpreter. Must be set BEFORE + // VMManager::Initialize. (--vu0diff toggles only VU0; the EE stays interp in + // both passes so the only moving part is the VU0 micro engine.) + if (s_mode == RunMode::SelfCheck || s_mode == RunMode::Vu0Diff) + s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", false); + + // The checkpoint-anchored modes emit the per-block divtrace hook into every + // JIT block prologue (used by the zoom's sparse JIT stream). g_emit_block_hook + // is read at block-compile time, so it must be set before the recompiler is + // initialized; it stays true for the whole process (the interp passes simply + // never compile EE blocks, and the hook is a g_enabled-gated no-op otherwise). + // Production builds never set it, so they emit nothing. + if (s_mode == RunMode::Localize || s_mode == RunMode::Repro || s_mode == RunMode::StepDiff) + ee_divtrace::g_emit_block_hook = true; + + // Mirror the JIT's recSYSCALL FlushCache/iFlushCache skip in the golden interp + // passes so both timelines stay bit-identical across that ABI-benign divergence + // (otherwise the JIT skip vs interp-runs-handler shows up as a register + kernel- + // stack diff that masks real bugs downstream). Harmless for --selfcheck (both + // interp runs skip identically), so we set it for every mode the runner uses. + ee_divtrace::g_skip_flushcache_syscall = true; + + // EERUNNER_NOFP=1: drop the FPU register file + ACC from the alignment + // fingerprint and the diff helpers, so the zoom walks PAST FP-register + // divergences to hunt a non-FP (integer / control-flow) divergence. Use when the + // FP path is known benign (Burnout 3: hang persists with the EE-FPU fully + // converged to interp — the real bug is the integer cond_b/pointer math, and the + // pervasive 1-ULP div.s noise was masking it). Combine with EERUNNER_FPUFULL=1 to + // also converge mul/add and minimize FP laundered into GPRs via store/reload. + if (const char* e = std::getenv("EERUNNER_NOFP")) + ee_divtrace::g_fp_exclude = (e[0] != '0'); + + // Override settings that shouldn't be picked up from defaults or INIs. + EERunner::SettingsOverride(); + + std::atomic thread_ret; + std::thread cputhread(CPUThreadMain, ¶ms, &thread_ret); + cputhread.join(); + + return thread_ret.load(); +} + +#ifdef _WIN32 + +int wmain(int argc, wchar_t** argv) +{ + std::vector u8_args; + u8_args.reserve(static_cast(argc)); + for (int i = 0; i < argc; i++) + u8_args.push_back(StringUtil::WideStringToUTF8String(argv[i])); + + std::vector u8_argptrs; + u8_argptrs.reserve(u8_args.size()); + for (int i = 0; i < argc; i++) + u8_argptrs.push_back(u8_args[i].data()); + u8_argptrs.push_back(nullptr); + + return real_main(argc, u8_argptrs.data()); +} + +#endif // _WIN32 diff --git a/pcsx2-gsrunner/CMakeLists.txt b/pcsx2-gsrunner/CMakeLists.txt index 9c69e4cf94..63f31081cf 100644 --- a/pcsx2-gsrunner/CMakeLists.txt +++ b/pcsx2-gsrunner/CMakeLists.txt @@ -19,3 +19,39 @@ target_link_libraries(pcsx2-gsrunner PRIVATE PCSX2_FLAGS PCSX2 ) + +if(WAYLAND_API AND UNIX AND NOT APPLE) + find_program(WAYLAND_SCANNER_EXECUTABLE NAMES wayland-scanner REQUIRED) + + # pkg_get_variable() needs CMake 3.18; the project minimum is 3.16 + # (Ubuntu 20.04 LTS ships 3.16.3), so query pkg-config directly. + execute_process( + COMMAND ${PKG_CONFIG_EXECUTABLE} --variable=pkgdatadir wayland-protocols + OUTPUT_VARIABLE WAYLAND_PROTOCOLS_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(NOT WAYLAND_PROTOCOLS_DIR) + message(FATAL_ERROR + "wayland-protocols not found via pkg-config. " + "Install wayland-protocols-devel (Fedora) / libwayland-dev (Debian).") + endif() + + set(_xdg_xml "${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml") + set(_xdg_h "${CMAKE_CURRENT_BINARY_DIR}/xdg-shell-client-protocol.h") + set(_xdg_c "${CMAKE_CURRENT_BINARY_DIR}/xdg-shell-protocol.c") + + add_custom_command(OUTPUT ${_xdg_h} + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} client-header ${_xdg_xml} ${_xdg_h} + DEPENDS ${_xdg_xml} VERBATIM) + add_custom_command(OUTPUT ${_xdg_c} + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} private-code ${_xdg_xml} ${_xdg_c} + DEPENDS ${_xdg_xml} VERBATIM) + + target_sources(pcsx2-gsrunner PRIVATE ${_xdg_h} ${_xdg_c}) + target_include_directories(pcsx2-gsrunner PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + # Generated C file; skip the C++ PCH which #includes . + set_source_files_properties(${_xdg_c} PROPERTIES SKIP_PRECOMPILE_HEADERS ON) + + pkg_check_modules(WAYLAND_CLIENT REQUIRED wayland-client) + target_link_libraries(pcsx2-gsrunner PRIVATE ${WAYLAND_CLIENT_LIBRARIES}) + target_include_directories(pcsx2-gsrunner PRIVATE ${WAYLAND_CLIENT_INCLUDE_DIRS}) +endif() diff --git a/pcsx2-gsrunner/Main.cpp b/pcsx2-gsrunner/Main.cpp index 17bf1c398f..1952d8585c 100644 --- a/pcsx2-gsrunner/Main.cpp +++ b/pcsx2-gsrunner/Main.cpp @@ -49,7 +49,7 @@ #include "svnrev.h" // Down here because X11 has a lot of defines that can conflict -#if defined(__linux__) +#if defined(__linux__) && defined(X11_API) #include #include #include @@ -106,8 +106,10 @@ static u64 s_total_draws_rov = 0; static u64 s_total_barriers_rov = 0; static u32 s_total_frames = 0; static u32 s_total_drawn_frames = 0; +static std::vector s_extended_stats_snapshot; static bool s_perf_enable = false; +static bool s_force_vsync = false; static float s_perf_updates = 0.0f; static float s_perf_sum_fps = 0.0f; static float s_perf_sum_internal_fps = 0.0f; @@ -511,6 +513,14 @@ static void PrintCommandLineHelp(const char* progname) std::fprintf(stderr, " -logfile : Writes emu log to filename.\n"); std::fprintf(stderr, " -noshadercache: Disables the shader cache (useful for parallel runs).\n"); std::fprintf(stderr, " -perf: Enable frame timing performance stats.\n"); + std::fprintf(stderr, " -vsync: Force vsync on (FIFO present mode). Workaround for libmali Wayland WSI which " + "advertises MAILBOX support but errors VK_ERROR_INITIALIZATION_FAILED on swapchain create.\n"); + std::fprintf(stderr, " -no-fb-fetch: Disable Vulkan framebuffer fetch (VK_EXT_rasterization_order_attachment_access). " + "Use to A/B against drivers that mishandle subpass self-dependencies (e.g. libmali).\n"); + std::fprintf(stderr, " -no-vs-expand: Disable vertex-shader point/line/sprite expansion (storage-buffer path). " + "Falls back to hardware/geometry expansion.\n"); + std::fprintf(stderr, " -no-tex-barriers: Force OverrideTextureBarriers=0. Disables the texture-barrier render-pass pattern " + "and the framebuffer-fetch / depth-feedback paths that build on it.\n"); std::fprintf(stderr, " --: Signals that no more arguments will follow and the remaining\n" " parameters make up the filename. Use when the filename contains\n" " spaces or starts with a dash.\n"); @@ -803,6 +813,30 @@ bool GSRunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& pa s_perf_enable = true; continue; } + else if (CHECK_ARG("-vsync")) + { + Console.WriteLn("Forcing vsync on (FIFO present mode). Use on libmali Wayland where MAILBOX errors VK_ERROR_INITIALIZATION_FAILED."); + s_force_vsync = true; + continue; + } + else if (CHECK_ARG("-no-fb-fetch")) + { + Console.WriteLn("Disabling framebuffer fetch (VK_EXT_rasterization_order_attachment_access)"); + s_settings_interface.SetBoolValue("EmuCore/GS", "DisableFramebufferFetch", true); + continue; + } + else if (CHECK_ARG("-no-vs-expand")) + { + Console.WriteLn("Disabling vertex-shader point/line/sprite expansion"); + s_settings_interface.SetBoolValue("EmuCore/GS", "DisableVertexShaderExpand", true); + continue; + } + else if (CHECK_ARG("-no-tex-barriers")) + { + Console.WriteLn("Forcing texture barriers off (OverrideTextureBarriers=0)"); + s_settings_interface.SetIntValue("EmuCore/GS", "OverrideTextureBarriers", 0); + continue; + } else if (CHECK_ARG("-debugdevice")) { Console.WriteLn("Enable debug device"); @@ -871,8 +905,12 @@ bool GSRunner::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& pa void GSRunner::SettingsOverride() { // complete as quickly as possible - s_settings_interface.SetBoolValue("EmuCore/GS", "FrameLimitEnable", false); - s_settings_interface.SetIntValue("EmuCore/GS", "VsyncEnable", false); + s_settings_interface.SetBoolValue("EmuCore/GS", "FrameLimitEnable", s_force_vsync); + s_settings_interface.SetIntValue("EmuCore/GS", "VsyncEnable", s_force_vsync); + // -vsync needs DisableMailboxPresentation too: GetEffectiveVSyncMode() returns + // Mailbox when VsyncEnable=true unless this is set. + if (s_force_vsync) + s_settings_interface.SetBoolValue("EmuCore/GS", "DisableMailboxPresentation", true); // Force screenshot quality settings to something more performant, overriding any defaults good for users. s_settings_interface.SetIntValue("EmuCore/GS", "ScreenshotFormat", static_cast(GSScreenshotFormat::PNG)); @@ -932,6 +970,8 @@ void GSRunner::DumpStats() Console.WriteLn(fmt::format("@HWSTAT@ Average GS Thread Time: {:.3f} ms", s_perf_sum_gs_thread_time / s_perf_updates)); Console.WriteLn(fmt::format("@HWSTAT@ Average GPU Time: {:.3f} ms", s_perf_sum_gpu_time / s_perf_updates)); } + for (const std::string& line : s_extended_stats_snapshot) + Console.WriteLn(fmt::format("@HWSTAT@ {}", line)); Console.WriteLn("============================================"); } @@ -955,6 +995,9 @@ static void CPUThreadMain(VMBootParameters* params, std::atomic* ret) // run until end GSDumpReplayer::SetLoopCount(s_loop_count); VMManager::SetState(VMState::Running); + // gsrunner is diagnostic-by-design; always collect extended stats so DumpStats has data. + if (g_gs_device) + g_gs_device->EnableExtendedStats(true); if (s_perf_enable) { VMManager::SetLimiterMode(LimiterModeType::Unlimited); @@ -962,6 +1005,9 @@ static void CPUThreadMain(VMBootParameters* params, std::atomic* ret) } while (VMManager::GetState() == VMState::Running) VMManager::Execute(); + // Snapshot backend-specific stats before the GS device is destroyed. + if (g_gs_device) + s_extended_stats_snapshot = g_gs_device->GetExtendedStats(); VMManager::Shutdown(false); GSRunner::DumpStats(); ret->store(EXIT_SUCCESS); @@ -972,11 +1018,22 @@ static void CPUThreadMain(VMBootParameters* params, std::atomic* ret) GSRunner::StopPlatformMessagePump(); } +// Set by the SIGINT/SIGTERM handlers (async-signal-safe: just an atomic store) +// and consumed on the CPU thread in PumpMessagesOnCPUThread(), which issues the +// actual VMManager::SetState(Stopping). Calling SetState() from signal context +// is not async-signal-safe — it can assert/log, take mutexes, and WaitGS/WaitVU. +static std::atomic s_signal_stop_requested{false}; + int main(int argc, char* argv[]) { CrashHandler::Install(); GSRunner::InitializeConsole(); + // Clean SIGINT/SIGTERM → VM stop, so DumpStats() still fires on ^C or SIGTERM during -loop 0. + // Defer the actual stop to the CPU thread (see s_signal_stop_requested). + std::signal(SIGINT, [](int) { s_signal_stop_requested.store(true); }); + std::signal(SIGTERM, [](int) { s_signal_stop_requested.store(true); }); + if (!GSRunner::InitializeConfig()) { Console.Error("Failed to initialize config."); @@ -1008,6 +1065,11 @@ int main(int argc, char* argv[]) void Host::PumpMessagesOnCPUThread() { + // Honor a pending ^C / SIGTERM here, on the CPU thread, where SetState() is + // safe to call. exchange() makes the transition fire exactly once. + if (s_signal_stop_requested.exchange(false)) + VMManager::SetState(VMState::Stopping); + // update GS thread copy of frame number MTGS::RunOnGSThread([frame_number = GSDumpReplayer::GetFrameNumber()]() { s_dump_frame_number = frame_number; }); MTGS::RunOnGSThread([loop_number = GSDumpReplayer::GetLoopCount()]() { s_loop_number = loop_number; }); @@ -1036,7 +1098,7 @@ std::string Host::TranslatePluralToString(const char* context, const char* msg, if (pos == std::string::npos) break; - ret.replace(pos, pos + 2, count_str.view()); + ret.replace(pos, 2, count_str.view()); } return ret; @@ -1215,7 +1277,206 @@ void GSRunner::StopPlatformMessagePump() CocoaTools::StopMainThreadEventLoop(); } -#elif defined(__linux__) +#elif defined(__linux__) && defined(WAYLAND_API) +// Wayland frontend for gsrunner. Used on handheld targets where the GPU's +// libmali variant is built for Wayland WSI (vkCreateWaylandSurfaceKHR) and +// VK_KHR_display is half-implemented (returns present_supported=false on the +// sole queue family). Runs as a normal Wayland client alongside the running +// compositor — no need to stop sway/weston. + +#include +#include "xdg-shell-client-protocol.h" +#include +#include + +static wl_display* s_display = nullptr; +static wl_registry* s_registry = nullptr; +static wl_compositor* s_compositor = nullptr; +static xdg_wm_base* s_wm_base = nullptr; +static wl_surface* s_surface = nullptr; +static xdg_surface* s_xdg_surface = nullptr; +static xdg_toplevel* s_xdg_toplevel = nullptr; +static WindowInfo s_wi; +static std::atomic s_shutdown_requested{false}; +static bool s_initial_configure_received = false; + +static void wl_wm_base_ping(void*, xdg_wm_base* wm_base, uint32_t serial) +{ + xdg_wm_base_pong(wm_base, serial); +} +static const xdg_wm_base_listener s_wm_base_listener = {wl_wm_base_ping}; + +static void wl_xdg_surface_configure(void*, xdg_surface* xs, uint32_t serial) +{ + xdg_surface_ack_configure(xs, serial); + s_initial_configure_received = true; +} +static const xdg_surface_listener s_xdg_surface_listener = {wl_xdg_surface_configure}; + +static void wl_xdg_toplevel_configure(void*, xdg_toplevel*, int32_t width, int32_t height, wl_array*) +{ + if (width > 0 && height > 0) + { + s_wi.surface_width = static_cast(width); + s_wi.surface_height = static_cast(height); + } +} +static void wl_xdg_toplevel_close(void*, xdg_toplevel*) +{ + s_shutdown_requested.store(true); +} +// Stubs for the newer xdg_toplevel_listener slots. These struct members exist +// only when the wayland-scanner-generated header was built against a new enough +// xdg-shell (configure_bounds: protocol v4 / wayland-protocols >= 1.20; +// wm_capabilities: v5 / >= 1.26). Guard both the stubs and their initializer +// slots on the matching SINCE_VERSION macros so the aggregate initializer always +// matches the generated struct's member count — without the guards this is a hard +// "too many initializers" build break on older protocol headers. +#ifdef XDG_TOPLEVEL_CONFIGURE_BOUNDS_SINCE_VERSION +static void wl_xdg_toplevel_configure_bounds(void*, xdg_toplevel*, int32_t, int32_t) {} +#endif +#ifdef XDG_TOPLEVEL_WM_CAPABILITIES_SINCE_VERSION +static void wl_xdg_toplevel_wm_capabilities(void*, xdg_toplevel*, wl_array*) {} +#endif +static const xdg_toplevel_listener s_xdg_toplevel_listener = { + wl_xdg_toplevel_configure, + wl_xdg_toplevel_close, +#ifdef XDG_TOPLEVEL_CONFIGURE_BOUNDS_SINCE_VERSION + wl_xdg_toplevel_configure_bounds, +#endif +#ifdef XDG_TOPLEVEL_WM_CAPABILITIES_SINCE_VERSION + wl_xdg_toplevel_wm_capabilities, +#endif +}; + +static void wl_registry_global(void*, wl_registry* registry, uint32_t name, const char* interface, uint32_t version) +{ + if (std::strcmp(interface, wl_compositor_interface.name) == 0) + { + s_compositor = static_cast( + wl_registry_bind(registry, name, &wl_compositor_interface, std::min(version, 4u))); + } + else if (std::strcmp(interface, xdg_wm_base_interface.name) == 0) + { + s_wm_base = static_cast( + wl_registry_bind(registry, name, &xdg_wm_base_interface, std::min(version, 4u))); + xdg_wm_base_add_listener(s_wm_base, &s_wm_base_listener, nullptr); + } +} +static void wl_registry_global_remove(void*, wl_registry*, uint32_t) {} +static const wl_registry_listener s_registry_listener = {wl_registry_global, wl_registry_global_remove}; + +bool GSRunner::CreatePlatformWindow() +{ + pxAssertRel(!s_display && !s_surface, "Tried to create window when there already was one!"); + + s_display = wl_display_connect(nullptr); + if (!s_display) + { + Console.Error("wl_display_connect failed (check $WAYLAND_DISPLAY)"); + return false; + } + + s_registry = wl_display_get_registry(s_display); + wl_registry_add_listener(s_registry, &s_registry_listener, nullptr); + wl_display_roundtrip(s_display); + + if (!s_compositor || !s_wm_base) + { + Console.Error("Wayland compositor missing wl_compositor or xdg_wm_base"); + DestroyPlatformWindow(); + return false; + } + + s_surface = wl_compositor_create_surface(s_compositor); + s_xdg_surface = xdg_wm_base_get_xdg_surface(s_wm_base, s_surface); + xdg_surface_add_listener(s_xdg_surface, &s_xdg_surface_listener, nullptr); + s_xdg_toplevel = xdg_surface_get_toplevel(s_xdg_surface); + xdg_toplevel_add_listener(s_xdg_toplevel, &s_xdg_toplevel_listener, nullptr); + xdg_toplevel_set_title(s_xdg_toplevel, "PCSX2 GS Runner"); + xdg_toplevel_set_app_id(s_xdg_toplevel, "net.pcsx2.gsrunner"); + + wl_surface_commit(s_surface); + // Round-trip until the compositor acks our initial configure, so the + // Vulkan WSI sees a properly-sized surface from the first swapchain. + while (!s_initial_configure_received) + { + if (wl_display_dispatch(s_display) < 0) + { + Console.Error("wl_display_dispatch failed during initial configure"); + DestroyPlatformWindow(); + return false; + } + } + + s_wi.type = WindowInfo::Type::Wayland; + s_wi.display_connection = s_display; + s_wi.window_handle = s_surface; + if (s_wi.surface_width == 0) + s_wi.surface_width = WINDOW_WIDTH; + if (s_wi.surface_height == 0) + s_wi.surface_height = WINDOW_HEIGHT; + s_wi.surface_scale = 1.0f; + return true; +} + +void GSRunner::DestroyPlatformWindow() +{ + if (s_xdg_toplevel) { xdg_toplevel_destroy(s_xdg_toplevel); s_xdg_toplevel = nullptr; } + if (s_xdg_surface) { xdg_surface_destroy(s_xdg_surface); s_xdg_surface = nullptr; } + if (s_surface) { wl_surface_destroy(s_surface); s_surface = nullptr; } + if (s_wm_base) { xdg_wm_base_destroy(s_wm_base); s_wm_base = nullptr; } + if (s_compositor) { wl_compositor_destroy(s_compositor); s_compositor = nullptr; } + if (s_registry) { wl_registry_destroy(s_registry); s_registry = nullptr; } + if (s_display) { wl_display_disconnect(s_display); s_display = nullptr; } +} + +std::optional GSRunner::GetPlatformWindowInfo() +{ + WindowInfo wi; + if (s_display && s_surface) + wi = s_wi; + else + wi.type = WindowInfo::Type::Surfaceless; + return wi; +} + +void GSRunner::PumpPlatformMessages(bool forever) +{ + if (!s_display) + return; + + if (!forever) + { + wl_display_flush(s_display); + wl_display_dispatch_pending(s_display); + return; + } + + const int fd = wl_display_get_fd(s_display); + while (!s_shutdown_requested.load()) + { + wl_display_flush(s_display); + pollfd pfd = {fd, POLLIN, 0}; + const int p = poll(&pfd, 1, 16); // cap so we keep checking shutdown + if (p > 0 && (pfd.revents & POLLIN)) + { + if (wl_display_dispatch(s_display) < 0) + break; + } + else + { + wl_display_dispatch_pending(s_display); + } + } +} + +void GSRunner::StopPlatformMessagePump() +{ + s_shutdown_requested.store(true); +} + +#elif defined(__linux__) && defined(X11_API) static Display* s_display = nullptr; static Window s_window = None; static WindowInfo s_wi; @@ -1329,4 +1590,52 @@ void GSRunner::StopPlatformMessagePump() { s_shutdown_requested.store(true); } -#endif // _WIN32 / __APPLE__ + +#elif defined(__linux__) +// No X11/Wayland on this build (handheld kmsdrm target). Vulkan VK_KHR_display +// owns the screen; VulkanDirect is reported with the requested resolution and +// the GS device's display backend enumerates the monitor itself. Mirrors +// pcsx2-sdl/Main.cpp::BuildWindowInfo. +static std::atomic s_shutdown_requested{false}; + +bool GSRunner::CreatePlatformWindow() +{ + return true; +} + +void GSRunner::DestroyPlatformWindow() +{ +} + +std::optional GSRunner::GetPlatformWindowInfo() +{ + WindowInfo wi; + if (s_use_window.value_or(true)) + { + wi.type = WindowInfo::Type::VulkanDirect; + wi.surface_width = WINDOW_WIDTH; + wi.surface_height = WINDOW_HEIGHT; + wi.surface_scale = 1.0f; + } + else + { + wi.type = WindowInfo::Type::Surfaceless; + } + return wi; +} + +void GSRunner::PumpPlatformMessages(bool forever) +{ + if (!forever) + return; + + while (!s_shutdown_requested.load()) + std::this_thread::sleep_for(std::chrono::milliseconds(16)); +} + +void GSRunner::StopPlatformMessagePump() +{ + s_shutdown_requested.store(true); +} + +#endif // _WIN32 / __APPLE__ / __linux__ diff --git a/pcsx2-sdl/CMakeLists.txt b/pcsx2-sdl/CMakeLists.txt new file mode 100644 index 0000000000..1ddcf8406a --- /dev/null +++ b/pcsx2-sdl/CMakeLists.txt @@ -0,0 +1,35 @@ +add_executable(pcsx2-sdl) + +if (PACKAGE_MODE) + install(TARGETS pcsx2-sdl DESTINATION ${CMAKE_INSTALL_BINDIR}) +else() + install(TARGETS pcsx2-sdl DESTINATION ${CMAKE_SOURCE_DIR}/bin) +endif() + +target_sources(pcsx2-sdl PRIVATE + Main.cpp +) + +target_include_directories(pcsx2-sdl PRIVATE + "${CMAKE_BINARY_DIR}/common/include" + "${CMAKE_SOURCE_DIR}/pcsx2" +) + +target_link_libraries(pcsx2-sdl PRIVATE + PCSX2_FLAGS + PCSX2 + SDL3::SDL3 +) + +# Deterministic process layout for the persisted-JIT VU program cache: a +# non-PIE executable (ET_EXEC) loads at its fixed link address even with +# ASLR enabled, so every libpcsx2 symbol address is run-invariant — +# paired with the image-anchored fixed-base arena reservation in +# SysMemory::AllocateVirtualMemory. This is what lets cached VU code +# reload across boots on the handheld without repatching baked +# addresses. Linux-only; libpcsx2's -fPIC objects link into an ET_EXEC +# fine. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_target_properties(pcsx2-sdl PROPERTIES POSITION_INDEPENDENT_CODE OFF) + target_link_options(pcsx2-sdl PRIVATE -no-pie) +endif() diff --git a/pcsx2-sdl/Main.cpp b/pcsx2-sdl/Main.cpp new file mode 100644 index 0000000000..aabd816901 --- /dev/null +++ b/pcsx2-sdl/Main.cpp @@ -0,0 +1,936 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// pcsx2-sdl — SDL3 frontend for kmsdrm-only handhelds. +// +// Boots a game from a CLI ISO path, or wires PCSX2's existing FullscreenUI +// (ImGui) for in-game settings, game-picker, and configuration. With no ISO +// supplied (and the "UI"/"StartBigPictureMode" flag set, which is on by +// default for this frontend), comes up directly into the FullscreenUI +// game-picker. +// +// Display surface is acquired via Vulkan VK_KHR_display, so no Wayland/X11 +// compositor is needed. The Vulkan renderer enumerates monitors itself; this +// frontend only reports the requested resolution back through WindowInfo. +// +// Audio + input come from SDL3 via the existing SDLAudioStream / SDLInputSource +// modules in the core (already linked, already non-Qt). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "fmt/format.h" + +#include "common/Assertions.h" +#include "common/Console.h" +#include "common/CrashHandler.h" +#include "common/Error.h" +#include "common/FileSystem.h" +#include "common/Path.h" +#include "common/ProgressCallback.h" +#include "common/StringUtil.h" +#include "common/Threading.h" + +#include "pcsx2/PrecompiledHeader.h" + +#include "pcsx2/Achievements.h" +#include "pcsx2/CDVD/CDVDcommon.h" +#include "pcsx2/GS.h" +#include "pcsx2/GameList.h" +#include "pcsx2/Host.h" +#include "pcsx2/INISettingsInterface.h" +#include "pcsx2/ImGui/FullscreenUI.h" +#include "pcsx2/ImGui/ImGuiFullscreen.h" +#include "pcsx2/ImGui/ImGuiManager.h" +#include "pcsx2/Input/InputManager.h" +#include "pcsx2/MTGS.h" +#include "pcsx2/PerformanceMetrics.h" +#include "pcsx2/SIO/Pad/Pad.h" +#include "pcsx2/VMManager.h" + +#include "svnrev.h" + +namespace Pcsx2SDL +{ + static bool InitializeConfig(); + static bool ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params); + static void InstallSignalHandler(); + static std::optional BuildWindowInfo(); + static void CPUThreadMain(VMBootParameters initial_params, bool start_in_fsui, std::atomic* ret); + static void DrainCPUThreadQueue(); + static void StopGameListRefreshThread(); +} // namespace Pcsx2SDL + +// Settings persistence (INI on disk). +static std::unique_ptr s_base_settings; +static std::unique_ptr s_secrets_settings; + +// Shutdown signal from SIGTERM/SIGINT or VM exit. +static std::atomic s_shutdown_requested{false}; + +// Display mode requested via --fullscreen-mode (0 = let the renderer pick the +// display's preferred mode). +static u32 s_requested_width = 0; +static u32 s_requested_height = 0; + +// Pending CPU-thread callbacks queued by Host::RunOnCPUThread (FullscreenUI +// uses this to schedule work back from the GS thread, e.g. "user picked an +// ISO from the game list, please VMManager::Initialize it on the CPU thread"). +// Drained by Host::PumpMessagesOnCPUThread which the VM polls every vsync; +// also drained on a 16ms tick by the idle loop while no VM is running. +static std::mutex s_cpu_queue_lock; +static std::deque> s_cpu_queue; +static std::condition_variable s_cpu_queue_cv; +// Set once the CPU thread is up; used to detect "RunOnCPUThread(block=true)" +// being called from the CPU thread itself (which would self-deadlock). +static std::atomic s_cpu_thread_id{}; + +// Background game-list scanner. FullscreenUI's GameList page calls +// Host::RefreshGameListAsync, which spawns a single worker thread to +// GameList::Refresh. The thread is joined (blocking) at shutdown and at +// restart, to avoid two scans racing. +static std::thread s_gamelist_thread; +static std::atomic s_gamelist_running{false}; + +////////////////////////////////////////////////////////////////////////// +// Settings + lifecycle +////////////////////////////////////////////////////////////////////////// + +bool Pcsx2SDL::InitializeConfig() +{ + EmuFolders::SetAppRoot(); + if (!EmuFolders::SetResourcesDirectory() || !EmuFolders::SetDataDirectory(nullptr)) + return false; + + CrashHandler::SetWriteDirectory(EmuFolders::DataRoot); + + const char* hw_check_error = nullptr; + if (!VMManager::PerformEarlyHardwareChecks(&hw_check_error)) + { + Console.ErrorFmt("Early hardware check failed: {}", hw_check_error ? hw_check_error : "unknown"); + return false; + } + + // Load Roboto for OSD / FullscreenUI (font is bundled in resources). + { + const std::string roboto_path = + EmuFolders::GetOverridableResourcePath("fonts" FS_OSPATH_SEPARATOR_STR "Roboto-Regular.ttf"); + const auto roboto_data = FileSystem::MapBinaryFileForRead(roboto_path.c_str()); + if (roboto_data.empty()) + { + Console.ErrorFmt("Failed to load font file '{}'.", roboto_path); + return false; + } + + std::vector fonts; + ImGuiManager::FontInfo fi{}; + fi.data = roboto_data; + fi.exclude_ranges = {}; + fi.face_name = nullptr; + fi.is_emoji_font = false; + fonts.push_back(fi); + ImGuiManager::SetFonts(std::move(fonts)); + } + + // Open / create the persistent INI at $XDG_CONFIG_HOME/PCSX2/PCSX2.ini. + const std::string ini_path = Path::Combine(EmuFolders::Settings, "PCSX2.ini"); + const bool ini_exists = FileSystem::FileExists(ini_path.c_str()); + Console.WriteLnFmt("Loading config from {}.", ini_path); + + s_base_settings = std::make_unique(ini_path); + Host::Internal::SetBaseSettingsLayer(s_base_settings.get()); + + if (!ini_exists || !s_base_settings->Load() || !VMManager::Internal::CheckSettingsVersion()) + { + Console.WriteLnFmt("Initialising fresh config at {}.", ini_path); + VMManager::SetDefaultSettings(*s_base_settings, true, true, true, true, true); + } + + // Secrets layer (achievements credentials etc). Created on first run; + // failure to create is non-fatal — just log and continue. + const std::string secrets_path = Path::Combine(EmuFolders::Settings, "secrets.ini"); + s_secrets_settings = std::make_unique(secrets_path); + Host::Internal::SetSecretsSettingsLayer(s_secrets_settings.get()); + if (FileSystem::FileExists(secrets_path.c_str())) + s_secrets_settings->Load(); + + // Apply handheld-frontend defaults. These override anything the INI + // might have for fields that don't make sense without a desktop: + // - Vulkan renderer (only one with VK_KHR_display direct-display path). + // - Fullscreen always (no window manager to support windowed mode). + // - SDL audio + SDL gamepad input (no Qt-coupled keyboard input layer). + { + auto lock = Host::GetSettingsLock(); + s_base_settings->SetBoolValue("InputSources", "SDL", true); + // Don't disable user-set values they may have customised — only fill + // in missing defaults for first-run. + if (!s_base_settings->ContainsValue("EmuCore/GS", "Renderer")) + s_base_settings->SetIntValue("EmuCore/GS", "Renderer", + static_cast(GSRendererType::VK)); + if (!s_base_settings->ContainsValue("SPU2/Output", "OutputModule")) + s_base_settings->SetStringValue("SPU2/Output", "OutputModule", "sdl"); + if (!s_base_settings->ContainsValue("EmuCore/GS", "FullscreenMode")) + s_base_settings->SetBoolValue("EmuCore/GS", "FullscreenMode", true); + } + + // Persist any first-run defaults so the user can hand-edit + // the INI between sessions. + Error save_error; + if (!s_base_settings->Save(&save_error)) + Console.ErrorFmt("Failed to save config: {}", save_error.GetDescription()); + + VMManager::Internal::LoadStartupSettings(); + return true; +} + +bool Pcsx2SDL::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params) +{ + for (int i = 1; i < argc; i++) + { +#define ARG(s) (!std::strcmp(argv[i], s)) +#define ARG_PARAM(s) (!std::strcmp(argv[i], s) && (i + 1) < argc) + + if (ARG("--help") || ARG("-h")) + { + std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV); + std::fprintf(stderr, "Usage: %s [options] \n\n", argv[0]); + std::fprintf(stderr, " --fullscreen-mode WxH Request a specific display mode\n"); + std::fprintf(stderr, " (default: monitor's preferred mode)\n"); + std::fprintf(stderr, " --bios-only Boot the BIOS without loading a disc\n"); + std::fprintf(stderr, " --state-from-file PATH Resume from a save state file\n"); + std::fprintf(stderr, " --no-fast-boot Skip fast boot (run full BIOS animation)\n"); + std::fprintf(stderr, " -h, --help Show this help and exit\n"); + std::fprintf(stderr, " --version Show version and exit\n"); + return false; + } + if (ARG("--version")) + { + std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV); + return false; + } + if (ARG_PARAM("--fullscreen-mode")) + { + const char* mode = argv[++i]; + const char* x_pos = std::strchr(mode, 'x'); + if (!x_pos) + { + Console.ErrorFmt("Invalid --fullscreen-mode '{}', expected WxH (e.g. 1280x720).", mode); + return false; + } + s_requested_width = StringUtil::FromChars(std::string_view(mode, x_pos - mode)).value_or(0); + s_requested_height = StringUtil::FromChars(x_pos + 1).value_or(0); + if (s_requested_width == 0 || s_requested_height == 0) + { + Console.ErrorFmt("Invalid --fullscreen-mode '{}'.", mode); + return false; + } + continue; + } + if (ARG("--bios-only")) + { + params.source_type = CDVD_SourceType::NoDisc; + continue; + } + if (ARG_PARAM("--state-from-file")) + { + params.save_state = argv[++i]; + continue; + } + if (ARG("--no-fast-boot")) + { + params.fast_boot = false; + continue; + } + if (argv[i][0] == '-') + { + Console.ErrorFmt("Unknown argument: '{}'", argv[i]); + return false; + } + + // Positional: ISO path. + if (!params.filename.empty()) + { + Console.Error("Multiple ISO paths supplied; expected exactly one."); + return false; + } + params.filename = argv[i]; + +#undef ARG +#undef ARG_PARAM + } + + // Empty positional + no --bios-only is allowed: the frontend boots into + // FullscreenUI's game-picker if "UI/StartBigPictureMode" is set + // (the default for this frontend; see Host::SetDefaultUISettings). + // The decision happens in main() after settings are loaded. + + if (!params.fast_boot.has_value()) + params.fast_boot = true; + if (!params.fullscreen.has_value()) + params.fullscreen = true; + + return true; +} + +static void HandleSignal(int) +{ + s_shutdown_requested.store(true, std::memory_order_release); + if (VMManager::HasValidVM()) + VMManager::SetState(VMState::Stopping); +} + +void Pcsx2SDL::InstallSignalHandler() +{ + std::signal(SIGTERM, &HandleSignal); + std::signal(SIGINT, &HandleSignal); + std::signal(SIGHUP, &HandleSignal); +} + +std::optional Pcsx2SDL::BuildWindowInfo() +{ + WindowInfo wi; + wi.type = WindowInfo::Type::VulkanDirect; + wi.surface_width = s_requested_width; + wi.surface_height = s_requested_height; + wi.surface_scale = 1.0f; + wi.display_connection = nullptr; + wi.window_handle = nullptr; + wi.surface_handle = nullptr; + return wi; +} + +////////////////////////////////////////////////////////////////////////// +// Host:: callbacks +////////////////////////////////////////////////////////////////////////// + +void Host::CommitBaseSettingChanges() +{ + if (!s_base_settings) + return; + Error err; + if (!s_base_settings->Save(&err)) + Console.ErrorFmt("Failed to save settings: {}", err.GetDescription()); +} + +void Host::LoadSettings(SettingsInterface& si, std::unique_lock& lock) +{ + // No host-specific settings layer to merge in — everything lives in the + // base INI. +} + +void Host::CheckForSettingsChanges(const Pcsx2Config& old_config) +{ +} + +bool Host::RequestResetSettings(bool folders, bool core, bool controllers, bool hotkeys, bool ui) +{ + // FullscreenUI will trigger this; no UI is wired to drive it yet. + return false; +} + +void Host::SetDefaultUISettings(SettingsInterface& si) +{ + // Handheld defaults — start straight into FullscreenUI when no game is + // loaded, hide pointer (no mouse), confirm via cross/A button. + si.SetBoolValue("UI", "StartBigPictureMode", true); +} + +bool Host::LocaleCircleConfirm() +{ + return false; +} + +std::unique_ptr Host::CreateHostProgressCallback() +{ + return ProgressCallback::CreateNullProgressCallback(); +} + +void Host::ReportInfoAsync(const std::string_view title, const std::string_view message) +{ + if (!title.empty() && !message.empty()) + INFO_LOG("{}: {}", title, message); + else if (!message.empty()) + INFO_LOG("{}", message); +} + +void Host::ReportErrorAsync(const std::string_view title, const std::string_view message) +{ + if (!title.empty() && !message.empty()) + ERROR_LOG("{}: {}", title, message); + else if (!message.empty()) + ERROR_LOG("{}", message); +} + +void Host::OpenURL(const std::string_view url) +{ +} + +bool Host::CopyTextToClipboard(const std::string_view text) +{ + return false; +} + +std::string Host::GetTextFromClipboard() +{ + return std::string(); +} + +void Host::BeginTextInput() +{ +} + +void Host::EndTextInput() +{ +} + +std::optional Host::GetTopLevelWindowInfo() +{ + return Pcsx2SDL::BuildWindowInfo(); +} + +void Host::OnInputDeviceConnected(const std::string_view identifier, const std::string_view device_name) +{ + INFO_LOG("Input device connected: {} ({})", identifier, device_name); +} + +void Host::OnInputDeviceDisconnected(const InputBindingKey key, const std::string_view identifier) +{ + INFO_LOG("Input device disconnected: {}", identifier); +} + +void Host::SetMouseMode(bool relative_mode, bool hide_cursor) +{ +} + +void Host::SetMouseLock(bool state) +{ +} + +std::optional Host::AcquireRenderWindow(bool recreate_window) +{ + return Pcsx2SDL::BuildWindowInfo(); +} + +void Host::ReleaseRenderWindow() +{ +} + +void Host::BeginPresentFrame() +{ +} + +void Host::RequestResizeHostDisplay(s32 width, s32 height) +{ + // VK_KHR_display provides a fixed mode for the lifetime of the surface; + // resize requests from the core are advisory only. +} + +void Host::OnVMStarting() +{ +} + +void Host::OnVMStarted() +{ +} + +void Host::OnVMDestroyed() +{ +} + +void Host::OnVMPaused() +{ +} + +void Host::OnVMResumed() +{ +} + +void Host::OnGameChanged(const std::string& title, const std::string& elf_override, const std::string& disc_path, + const std::string& disc_serial, u32 disc_crc, u32 current_crc) +{ + if (!title.empty()) + INFO_LOG("Game changed: {} (serial {}, CRC {:08X})", title, disc_serial, current_crc); +} + +void Host::OnPerformanceMetricsUpdated() +{ +} + +void Host::OnSaveStateLoading(const std::string_view filename) +{ +} + +void Host::OnSaveStateLoaded(const std::string_view filename, bool was_successful) +{ +} + +void Host::OnSaveStateSaved(const std::string_view filename) +{ +} + +void Pcsx2SDL::DrainCPUThreadQueue() +{ + for (;;) + { + std::function fn; + { + std::lock_guard lock(s_cpu_queue_lock); + if (s_cpu_queue.empty()) + return; + fn = std::move(s_cpu_queue.front()); + s_cpu_queue.pop_front(); + } + fn(); + } +} + +void Host::PumpMessagesOnCPUThread() +{ + // Honour SIGTERM / SIGINT picked up by the signal handler. + if (s_shutdown_requested.load(std::memory_order_acquire) && VMManager::HasValidVM()) + VMManager::SetState(VMState::Stopping); + + Pcsx2SDL::DrainCPUThreadQueue(); +} + +void Host::RunOnCPUThread(std::function function, bool block) +{ + if (block) + { + // Inline if already on the CPU thread to avoid self-deadlock. + if (s_cpu_thread_id.load(std::memory_order_acquire) == std::this_thread::get_id()) + { + function(); + return; + } + + std::mutex done_lock; + std::condition_variable done_cv; + bool done = false; + auto wrapped = [&function, &done_lock, &done_cv, &done]() { + function(); + std::lock_guard lk(done_lock); + done = true; + done_cv.notify_all(); + }; + { + std::lock_guard lock(s_cpu_queue_lock); + s_cpu_queue.emplace_back(std::move(wrapped)); + } + s_cpu_queue_cv.notify_all(); + std::unique_lock lk(done_lock); + done_cv.wait(lk, [&done]() { return done; }); + return; + } + + { + std::lock_guard lock(s_cpu_queue_lock); + s_cpu_queue.emplace_back(std::move(function)); + } + s_cpu_queue_cv.notify_all(); +} + +void Pcsx2SDL::StopGameListRefreshThread() +{ + if (!s_gamelist_thread.joinable()) + return; + s_gamelist_thread.join(); +} + +void Host::RefreshGameListAsync(bool invalidate_cache) +{ + // Only one scan at a time — FullscreenUI's "rescan" button can fire + // multiple times in quick succession; coalesce by joining the previous + // scan first. + Pcsx2SDL::StopGameListRefreshThread(); + + s_gamelist_running.store(true, std::memory_order_release); + s_gamelist_thread = std::thread([invalidate_cache]() { + Threading::SetNameOfCurrentThread("GameList Refresh"); + GameList::Refresh(invalidate_cache, false, nullptr); + s_gamelist_running.store(false, std::memory_order_release); + }); +} + +void Host::CancelGameListRefresh() +{ + Pcsx2SDL::StopGameListRefreshThread(); +} + +bool Host::IsFullscreen() +{ + return true; +} + +void Host::SetFullscreen(bool enabled) +{ + // No-op: VK_KHR_display is always fullscreen; there's no compositor to + // host a windowed mode. +} + +void Host::OnCaptureStarted(const std::string& filename) +{ +} + +void Host::OnCaptureStopped() +{ +} + +void Host::RequestExitApplication(bool allow_confirm) +{ + s_shutdown_requested.store(true, std::memory_order_release); + if (VMManager::HasValidVM()) + VMManager::SetState(VMState::Stopping); +} + +void Host::RequestExitBigPicture() +{ + // FullscreenUI exit — shut down the application. + Host::RequestExitApplication(false); +} + +void Host::RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool default_save_state) +{ + VMManager::SetState(VMState::Stopping); +} + +void Host::OnAchievementsLoginSuccess(const char* username, u32 points, u32 sc_points, u32 unread_messages) +{ +} + +void Host::OnAchievementsLoginRequested(Achievements::LoginRequestReason reason) +{ +} + +void Host::OnAchievementsHardcoreModeChanged(bool enabled) +{ +} + +void Host::OnAchievementsRefreshed() +{ +} + +void Host::OnCoverDownloaderOpenRequested() +{ +} + +void Host::OnCreateMemoryCardOpenRequested() +{ +} + +bool Host::InBatchMode() +{ + return false; +} + +bool Host::InNoGUIMode() +{ + return false; +} + +bool Host::ShouldPreferHostFileSelector() +{ + return false; +} + +void Host::OpenHostFileSelectorAsync(std::string_view title, bool select_directory, FileSelectorCallback callback, + FileSelectorFilters filters, std::string_view initial_directory) +{ + // No native file picker on a kmsdrm-only handheld. FullscreenUI's own + // game list / file browser handles this path. + callback(std::string()); +} + +int Host::LocaleSensitiveCompare(std::string_view lhs, std::string_view rhs) +{ + const int res = std::strncmp(lhs.data(), rhs.data(), std::min(lhs.size(), rhs.size())); + if (res != 0) + return res; + return lhs.size() > rhs.size() ? 1 : (lhs.size() < rhs.size() ? -1 : 0); +} + +s32 Host::Internal::GetTranslatedStringImpl( + const std::string_view context, const std::string_view msg, char* tbuf, size_t tbuf_space) +{ + if (msg.size() > tbuf_space) + return -1; + if (msg.empty()) + return 0; + + std::memcpy(tbuf, msg.data(), msg.size()); + return static_cast(msg.size()); +} + +std::string Host::TranslatePluralToString(const char* context, const char* msg, const char* disambiguation, int count) +{ + TinyString count_str = TinyString::from_format("{}", count); + + std::string ret(msg); + for (;;) + { + std::string::size_type pos = ret.find("%n"); + if (pos == std::string::npos) + break; + ret.replace(pos, 2, count_str.view()); + } + return ret; +} + +std::optional InputManager::ConvertHostKeyboardStringToCode(const std::string_view str) +{ + return std::nullopt; +} + +std::optional InputManager::ConvertHostKeyboardCodeToString(u32 code) +{ + return std::nullopt; +} + +const char* InputManager::ConvertHostKeyboardCodeToIcon(u32 code) +{ + return nullptr; +} + +BEGIN_HOTKEY_LIST(g_host_hotkeys) +END_HOTKEY_LIST() + +////////////////////////////////////////////////////////////////////////// +// CPU thread + main +////////////////////////////////////////////////////////////////////////// + +void Pcsx2SDL::CPUThreadMain(VMBootParameters initial_params, bool start_in_fsui, std::atomic* ret) +{ + ret->store(EXIT_FAILURE); + s_cpu_thread_id.store(std::this_thread::get_id(), std::memory_order_release); + + if (!VMManager::Internal::CPUThreadInitialize()) + { + Console.Error("CPU thread init failed."); + VMManager::Internal::CPUThreadShutdown(); + return; + } + + VMManager::ApplySettings(); + + // SDL doesn't enumerate already-connected gamepads at init time — the + // initial SDL_EVENT_GAMEPAD_ADDED events sit in SDL's queue until + // something calls SDL_PollEvent. Drain them now and rebind, so any + // SDL2-style A/B/X/Y face-button bindings get migrated to SDL3 positional + // names (FaceSouth/East/West/North) before use. Qt sidesteps this via its + // background-poll QTimer; the SDL frontend must drain explicitly here. + InputManager::ReloadDevices(); + VMManager::ReloadInputBindings(true); + + // Bring up the GS thread + display surface before booting anything when + // starting straight into FullscreenUI. With a VM-on-startup path the + // VMManager::Initialize call below will open MTGS itself; with the + // bootless FSUI path it must be opened manually so the game-picker is + // visible before the user selects a game. + if (start_in_fsui) + { + ImGuiManager::InitializeFullscreenUI(); + if (!MTGS::WaitForOpen()) + { + Console.Error("Failed to open MTGS for FullscreenUI startup."); + VMManager::Internal::CPUThreadShutdown(); + return; + } + MTGS::SetRunIdle(true); + } + + // The "initial" boot is the ISO/state passed on the CLI. When launching + // straight into FullscreenUI instead, this stays empty and the user picks + // a game from the game-list page (which queues a VMManager::Initialize + // call back via Host::RunOnCPUThread). + std::optional pending_boot; + if (!start_in_fsui) + pending_boot = std::move(initial_params); + + bool clean_shutdown = true; + + // Main CPU thread state-machine loop. Exits on shutdown_requested OR on + // VM shutdown when no FullscreenUI session is active to fall back to. + while (!s_shutdown_requested.load(std::memory_order_acquire)) + { + // Drain RunOnCPUThread callbacks (also done inside Execute via + // PumpMessagesOnCPUThread, but must be drained in the no-VM idle + // state too). + Pcsx2SDL::DrainCPUThreadQueue(); + + const VMState state = VMManager::GetState(); + switch (state) + { + case VMState::Initializing: + // Transient — just spin until VMManager moves on. + continue; + + case VMState::Running: + VMManager::Execute(); + continue; + + case VMState::Resetting: + VMManager::Reset(); + continue; + + case VMState::Stopping: + VMManager::Shutdown(false); + // After a clean shutdown, fall through to Shutdown / Paused. + continue; + + case VMState::Paused: + case VMState::Shutdown: + { + // If a CLI-supplied boot is pending, kick it now. + if (pending_boot.has_value()) + { + VMBootParameters bp = std::move(pending_boot.value()); + pending_boot.reset(); + const VMBootResult br = VMManager::Initialize(bp); + if (br != VMBootResult::StartupSuccess) + { + Console.ErrorFmt("VMManager::Initialize failed (result {}).", + static_cast(br)); + clean_shutdown = false; + s_shutdown_requested.store(true, std::memory_order_release); + break; + } + VMManager::SetState(VMState::Running); + continue; + } + + // Nothing pending. If no FSUI session is running, exit cleanly + // — the user's game finished and there is no UI to show next, + // so the frontend's job is done. + if (!start_in_fsui) + { + s_shutdown_requested.store(true, std::memory_order_release); + break; + } + + // FSUI idle: pump input so the gamepad can drive the menus. + // Qt does this from a background QTimer; here it has to run + // inline. Do it *outside* s_cpu_queue_lock — FSUI menu + // callbacks can call Host::RunOnCPUThread, which takes the + // same lock. + VMManager::IdlePollUpdate(); + + // Then wait for either a queued RunOnCPUThread (which might + // Initialize a new VM) or a state change. Bounded timeout so + // the shutdown flag is rechecked promptly. + std::unique_lock lock(s_cpu_queue_lock); + s_cpu_queue_cv.wait_for(lock, std::chrono::milliseconds(16), + []() { return !s_cpu_queue.empty(); }); + continue; + } + + default: + continue; + } + } + + // Tear down in reverse order of setup. MTGS may already have been closed + // by VMManager::Shutdown if VMManager opened it; if opened for FSUI + // directly it will still be open here. + if (VMManager::HasValidVM()) + VMManager::Shutdown(false); + if (MTGS::IsOpen()) + { + MTGS::SetRunIdle(false); + MTGS::WaitForClose(); + } + + Pcsx2SDL::StopGameListRefreshThread(); + + VMManager::Internal::CPUThreadShutdown(); + s_cpu_thread_id.store(std::thread::id{}, std::memory_order_release); + ret->store(clean_shutdown ? EXIT_SUCCESS : EXIT_FAILURE); +} + +int main(int argc, char* argv[]) +{ + // Short-circuit --help/--version before any heavyweight init so they + // work even on a system where the resources dir hasn't been laid down. + for (int i = 1; i < argc; i++) + { + if (!std::strcmp(argv[i], "--help") || !std::strcmp(argv[i], "-h")) + { + std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV); + std::fprintf(stderr, "Usage: %s [options] \n\n", argv[0]); + std::fprintf(stderr, " --fullscreen-mode WxH Request a specific display mode\n"); + std::fprintf(stderr, " (default: monitor's preferred mode)\n"); + std::fprintf(stderr, " --bios-only Boot the BIOS without loading a disc\n"); + std::fprintf(stderr, " --state-from-file PATH Resume from a save state file\n"); + std::fprintf(stderr, " --no-fast-boot Skip fast boot (run full BIOS animation)\n"); + std::fprintf(stderr, " -h, --help Show this help and exit\n"); + std::fprintf(stderr, " --version Show version and exit\n"); + return EXIT_SUCCESS; + } + if (!std::strcmp(argv[i], "--version")) + { + std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV); + return EXIT_SUCCESS; + } + } + + CrashHandler::Install(); + Log::SetConsoleOutputLevel(LOGLEVEL_INFO); + + if (!Pcsx2SDL::InitializeConfig()) + { + Console.Error("Failed to initialize config."); + return EXIT_FAILURE; + } + + VMBootParameters params; + if (!Pcsx2SDL::ParseCommandLineArgs(argc, argv, params)) + return EXIT_FAILURE; + + const bool have_boot_target = !params.filename.empty() || params.source_type.has_value(); + bool start_in_fsui = false; + if (!have_boot_target) + { + // No ISO supplied and no --bios-only — only valid if the + // StartBigPictureMode flag is set, in which case the frontend starts + // in the FullscreenUI game-picker. + if (Host::GetBaseBoolSettingValue("UI", "StartBigPictureMode", true)) + { + start_in_fsui = true; + } + else + { + Console.Error("No ISO path supplied. Use --bios-only to boot the BIOS, " + "or set UI/StartBigPictureMode=true in PCSX2.ini for the game-picker."); + return EXIT_FAILURE; + } + } + + Pcsx2SDL::InstallSignalHandler(); + SysMemory::ReserveMemory(); + + std::atomic thread_ret{EXIT_FAILURE}; + std::thread cpu_thread([&]() { + Pcsx2SDL::CPUThreadMain(std::move(params), start_in_fsui, &thread_ret); + }); + + // VK_KHR_display has no host event loop; SDL3 input pumping happens + // inside InputManager on the CPU thread; signals are async. The main + // thread just waits for shutdown. + cpu_thread.join(); + + return thread_ret.load(); +} diff --git a/pcsx2-vurunner/CMakeLists.txt b/pcsx2-vurunner/CMakeLists.txt new file mode 100644 index 0000000000..43f94f789d --- /dev/null +++ b/pcsx2-vurunner/CMakeLists.txt @@ -0,0 +1,43 @@ +add_executable(pcsx2-vurunner) + +if(PACKAGE_MODE) + install(TARGETS pcsx2-vurunner DESTINATION ${CMAKE_INSTALL_BINDIR}) +else() + install(TARGETS pcsx2-vurunner DESTINATION ${CMAKE_SOURCE_DIR}/bin) +endif() + +# vurunner reuses the recompiler test harness's environment + replay driver. +# Both the harness env and StubHost are also linked by the gtest binary; they +# are compiled in here directly (rather than as a shared library) to mirror +# pcsx2-gsrunner's single-binary-no-extra-libs pattern. +target_sources(pcsx2-vurunner PRIVATE + Main.cpp + ${CMAKE_SOURCE_DIR}/tests/ctest/core/StubHost.cpp + ${CMAKE_SOURCE_DIR}/tests/ctest/core/recompilers/harness/RecompilerTestEnvironment.cpp + ${CMAKE_SOURCE_DIR}/tests/ctest/core/recompilers/harness/VuSnapshot.cpp + ${CMAKE_SOURCE_DIR}/tests/ctest/core/recompilers/harness/VuReplay.cpp +) + +target_include_directories(pcsx2-vurunner PRIVATE + "${CMAKE_BINARY_DIR}/common/include" + "${CMAKE_SOURCE_DIR}/pcsx2" + "${CMAKE_SOURCE_DIR}/tests/ctest/core/recompilers" +) + +target_link_libraries(pcsx2-vurunner PRIVATE + PCSX2_FLAGS + PCSX2 +) + +# Deterministic process layout for the persisted-JIT VU program cache: a +# non-PIE executable (ET_EXEC) loads at its fixed link address even with +# ASLR enabled, so every libpcsx2 symbol address is run-invariant — +# paired with the image-anchored fixed-base arena reservation in +# SysMemory::AllocateVirtualMemory. Verify with `pcsx2-vurunner +# --print-bases` (two runs must print identical addresses). Linux-only; +# macOS has no non-PIE executables. libpcsx2's -fPIC objects link into an +# ET_EXEC fine. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set_target_properties(pcsx2-vurunner PROPERTIES POSITION_INDEPENDENT_CODE OFF) + target_link_options(pcsx2-vurunner PRIVATE -no-pie) +endif() diff --git a/pcsx2-vurunner/Main.cpp b/pcsx2-vurunner/Main.cpp new file mode 100644 index 0000000000..e7ec420f8f --- /dev/null +++ b/pcsx2-vurunner/Main.cpp @@ -0,0 +1,1115 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +// pcsx2-vurunner — headless VU microprogram replayer for codegen iteration. +// +// Loads .vucap files (produced by the live capture probe in mVUexecute, +// see pcsx2/vu_capture.h) and replays them through both the microVU JIT +// and the VU interpreter. Operating modes: +// +// --diff (default) — run JIT and interp, gtest-equivalent divergence diff +// --bench — run JIT only, measure cycles/insns/branch-misses per +// iter via PMU counters; report median + median-abs-dev +// --dump-asm — template-JIT host-code disasm to .codegen.s +// +// Modes can be combined; the binary runs each capture through each +// requested mode in turn. + +#include "harness/RecompilerTestEnvironment.h" +#include "harness/VuReplay.h" +#include "harness/VuSnapshot.h" + +#include "vu_capture.h" +#include "Config.h" +#include "Memory.h" +#include "VU.h" +#include "VUmicro.h" +#include "Gif_Unit.h" +#include "microVU_Divtrace.h" +#include "arm64/microVU_Persist-arm64.h" +#include "arm64/microVU_ProgCache-arm64.h" + +#include "DebugTools/Debug.h" +#include "common/FPControl.h" +#include "common/PmuCounters.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + +struct Options +{ + u32 iters = 1; + bool diff = false; + bool bench = false; + bool dump_asm = false; + bool dump_microcode = false; + bool divtrace = false; + bool bench_no_reprime = false; + bool print_bases = false; + bool no_progcache = false; // determinism gate: force program cache + recording off + u32 dump_count = 64; + u32 cycle_override = 0; // 0 = use captured budget + std::string cache_dir; // empty = persisted-JIT program cache off + std::vector files; +}; + +void PrintUsage(const char* argv0) +{ + std::fprintf(stderr, + "Usage: %s [options] file.vucap [file.vucap ...]\n" + "\n" + "Modes (combinable; default is --diff):\n" + " --diff Run JIT + interp, report architectural divergences.\n" + " --bench Run JIT only, measure PMU cycles/insns per iter.\n" + " --dump-asm Dump the TEMPLATE JIT's emitted host ARM64 to\n" + " .codegen.s. (ARM64 JIT only).\n" + " --dump-microcode Disassemble VU microcode starting at start_pc to stdout.\n" + " --divtrace Per-microvu-op state-snapshot diff between JIT and interp;\n" + " report the FIRST divergent op with full context. ARM64 only.\n" + " --print-bases Print the process-layout anchors (image base, data/code\n" + " arenas, VU rec slabs) and exit. Two consecutive runs of a\n" + " non-PIE build must print identical addresses — the\n" + " determinism gate for the persisted-JIT program cache.\n" + "\n" + "Options:\n" + " --cache-dir D Enable the persisted-JIT VU program cache rooted at D\n" + " (D/vu_jit/vu{0,1}/...). Turns on emit-time fixup\n" + " recording; programs compiled this run are saved as\n" + " .vuprog payloads at every block-cache reset, and\n" + " programs already on disk hydrate instead of\n" + " recompiling. Run twice with the same D for the\n" + " cross-process gate: the 2nd run must report\n" + " payloadHits>0 / blockCompiles=0 per capture.\n" + " --no-progcache Force the persisted-JIT program cache and emit-time\n" + " recording off for the whole process (overrides\n" + " --cache-dir) so a JIT-vs-interp diff run is\n" + " byte-reproducible.\n" + " --iters N Number of replay iterations per capture (default 1).\n" + " Bench: throw out the first iter (JIT compile cost).\n" + " --dump-count N Number of microinstructions to dump (default 64).\n" + " --cycles N Override captured cycle budget (0 = keep original, default).\n" + " --help Show this message.\n" + " -- Treat all subsequent args as filenames.\n" + "\n" + "Captures are produced by running pcsx2-qt under PCSX2_VU_CAPTURE_DIR=.\n", + argv0); +} + +bool ParseArgs(int argc, char** argv, Options& opts) +{ + bool end_of_options = false; + for (int i = 1; i < argc; ++i) + { + const std::string a = argv[i]; + if (end_of_options || a.empty() || a[0] != '-') + { + opts.files.push_back(a); + continue; + } + if (a == "--") + { + end_of_options = true; + } + else if (a == "--help" || a == "-h") + { + PrintUsage(argv[0]); + std::exit(0); + } + else if (a == "--diff") + { + opts.diff = true; + } + else if (a == "--bench") + { + opts.bench = true; + } + else if (a == "--dump-asm") + { + opts.dump_asm = true; + } + else if (a == "--dump-microcode") + { + opts.dump_microcode = true; + } + else if (a == "--divtrace") + { + opts.divtrace = true; + } + else if (a == "--bench-no-reprime") + { + opts.bench_no_reprime = true; + } + else if (a == "--print-bases") + { + opts.print_bases = true; + } + else if (a == "--no-progcache") + { + opts.no_progcache = true; + } + else if (a == "--cache-dir") + { + if (i + 1 >= argc) + { + std::fprintf(stderr, "vurunner: --cache-dir requires an argument\n"); + return false; + } + opts.cache_dir = argv[++i]; + if (opts.cache_dir.empty()) + { + std::fprintf(stderr, "vurunner: --cache-dir argument is empty\n"); + return false; + } + } + else if (a == "--dump-count") + { + if (i + 1 >= argc) + { + std::fprintf(stderr, "vurunner: --dump-count requires an argument\n"); + return false; + } + const long n = std::strtol(argv[++i], nullptr, 10); + if (n <= 0 || n > (1 << 16)) + { + std::fprintf(stderr, "vurunner: --dump-count must be in (0, 65536]\n"); + return false; + } + opts.dump_count = static_cast(n); + } + else if (a == "--cycles") + { + if (i + 1 >= argc) + { + std::fprintf(stderr, "vurunner: --cycles requires an argument\n"); + return false; + } + const long n = std::strtol(argv[++i], nullptr, 10); + if (n < 0) + { + std::fprintf(stderr, "vurunner: --cycles must be >= 0\n"); + return false; + } + opts.cycle_override = static_cast(n); + } + else if (a == "--iters") + { + if (i + 1 >= argc) + { + std::fprintf(stderr, "vurunner: --iters requires an argument\n"); + return false; + } + const long n = std::strtol(argv[++i], nullptr, 10); + if (n <= 0 || n > (1 << 24)) + { + std::fprintf(stderr, "vurunner: --iters must be in (0, 16M]\n"); + return false; + } + opts.iters = static_cast(n); + } + else + { + std::fprintf(stderr, "vurunner: unknown option '%s'\n", a.c_str()); + return false; + } + } + if (opts.files.empty() && !opts.print_bases) + { + std::fprintf(stderr, "vurunner: no input files\n"); + return false; + } + if (!opts.diff && !opts.bench && !opts.dump_asm + && !opts.dump_microcode && !opts.divtrace && !opts.print_bases) + opts.diff = true; + return true; +} + +int RunDumpMicrocode(const std::vector& records, + const std::vector& names, + u32 dump_count) +{ + for (size_t fi = 0; fi < records.size(); ++fi) + { + const auto& rec = records[fi]; + const u32 prog_size = static_cast(rec.microcode.size()); + const u32 limit = (rec.vu_index == 0) ? 0xFFFu : 0x3FFFu; + std::printf("// %s vu%u start_pc=0x%08X dump=%u insns\n", + names[fi].c_str(), rec.vu_index, rec.start_pc, dump_count); + + u32 pc = rec.start_pc & ~7u; + for (u32 i = 0; i < dump_count; ++i) + { + const u32 wrapped = pc & limit; + if (wrapped + 8 > prog_size) + break; + u32 lo = 0, up = 0; + std::memcpy(&lo, rec.microcode.data() + wrapped + 0, 4); + std::memcpy(&up, rec.microcode.data() + wrapped + 4, 4); + const char* upper_s = (rec.vu_index == 0) ? + disVU0MicroUF(up, pc + 4) : disVU1MicroUF(up, pc + 4); + std::string upper_copy = upper_s ? upper_s : "?"; + const char* lower_s = (rec.vu_index == 0) ? + disVU0MicroLF(lo, pc) : disVU1MicroLF(lo, pc); + std::printf(" %04X %08X %08X %-32s | %s\n", + wrapped, up, lo, upper_copy.c_str(), lower_s ? lower_s : "?"); + // Stop on E-bit (bit 30 of upper word) — end of program. + if (up & (1u << 30)) + { + std::printf(" // [E-bit] — program end\n"); + break; + } + pc += 8; + } + std::printf("\n"); + } + return 0; +} + +// Median + MAD of one PMU counter across samples (skipping the first sample +// when skip_warmup is set, since the JIT compile cost dominates iter 0). +struct Stat +{ + u64 median = 0; + u64 mad = 0; + u64 min = 0; + u64 max = 0; +}; + +Stat Summarize(const std::vector& xs) +{ + Stat s; + if (xs.empty()) + return s; + std::vector sorted = xs; + std::sort(sorted.begin(), sorted.end()); + s.min = sorted.front(); + s.max = sorted.back(); + s.median = sorted[sorted.size() / 2]; + + std::vector dev; + dev.reserve(sorted.size()); + for (u64 x : sorted) + dev.push_back(x > s.median ? x - s.median : s.median - x); + std::sort(dev.begin(), dev.end()); + s.mad = dev[dev.size() / 2]; + return s; +} + +// Returns the byte offset of the first byte that differs between two +// vectors, or -1 if they are byte-identical (including matching length). +ssize_t FirstByteDiff(const std::vector& a, const std::vector& b) +{ + const size_t n = std::min(a.size(), b.size()); + for (size_t i = 0; i < n; ++i) + if (a[i] != b[i]) + return static_cast(i); + if (a.size() != b.size()) + return static_cast(n); + return -1; +} + +void PrintPathDiff(const std::vector& jit, const std::vector& interp) +{ + if (jit == interp) + return; + std::printf(" PATH1: jit=%zu B interp=%zu B (lengths %s)\n", + jit.size(), interp.size(), jit.size() == interp.size() ? "match" : "DIFFER"); + const ssize_t at = FirstByteDiff(jit, interp); + if (at < 0) + return; + const size_t qw_off = static_cast(at) & ~size_t{15}; + std::printf(" PATH1: first byte diff at offset 0x%zx (qword 0x%zx)\n", + static_cast(at), qw_off); + auto dumpqw = [](const char* label, const std::vector& v, size_t off) { + if (off + 16 > v.size()) + { + std::printf(" %-6s [out of range, len=%zu]\n", label, v.size()); + return; + } + std::printf(" %-6s ", label); + for (int i = 15; i >= 0; --i) + std::printf("%02x", v[off + i]); + std::printf("\n"); + }; + for (int qi = -1; qi <= 1; ++qi) + { + const ssize_t off = static_cast(qw_off) + qi * 16; + if (off < 0) + continue; + std::printf(" [qw 0x%zx]\n", static_cast(off)); + dumpqw("jit", jit, static_cast(off)); + dumpqw("interp", interp, static_cast(off)); + } +} + +int RunDiff(const std::vector& records, + const std::vector& names, + u32 iters, + u32 cycle_override) +{ + int diverged_files = 0; + int path_diverged_files = 0; + for (size_t fi = 0; fi < records.size(); ++fi) + { + const auto& rec = records[fi]; + std::printf("[diff] %s (vu%u start_pc=0x%08X cycles=%u)\n", + names[fi].c_str(), rec.vu_index, rec.start_pc, rec.cycle_budget); + bool any_diverged = false; + bool path_diverged = false; + const char* term = "unknown"; + for (u32 i = 0; i < iters; ++i) + { + const auto r = recompiler_tests::ReplayCapture(rec, + recompiler_tests::VuDiffMode::PipelinePermissive, cycle_override); + if (!r.ok) + { + std::printf(" iter %u: replay setup failed\n", i); + any_diverged = true; + break; + } + // Termination signal (interp = oracle): ebit = program ran to its + // E-bit; budget = truncated by cycle budget (loop noise, not a bug). + if (i == 0) + term = r.interp_ebit ? "ebit" : "budget"; + const bool path_diff = (r.path1_packets_jit != r.path1_packets_interp); + if (r.diverged || path_diff) + { + any_diverged = true; + path_diverged = path_diff; + std::printf(" iter %u: %zu register divergence(s)%s\n", + i, r.diff_lines.size(), path_diff ? " [PATH1 DIFFERS]" : ""); + for (const auto& d : r.diff_lines) + std::printf(" %s\n", d.c_str()); + if (path_diff) + PrintPathDiff(r.path1_packets_jit, r.path1_packets_interp); + break; + } + } + std::printf(" term=%s\n", term); + if (!any_diverged) + std::printf(" ok (%u iters)\n", iters); + else + { + ++diverged_files; + if (path_diverged) + ++path_diverged_files; + } + } + if (diverged_files) + std::printf("[diff] %d of %zu captures diverged (%d with PATH1 byte diff)\n", + diverged_files, records.size(), path_diverged_files); + return diverged_files == 0 ? 0 : 2; +} + +int RunBench(const std::vector& records, + const std::vector& names, + u32 iters, + bool no_reprime) +{ + const u32 effective_iters = std::max(2u, iters); // need at least 1 warmup + 1 sample + std::printf("# bench: iters=%u (iter 0 dropped as warmup), counters: cycles instructions branch-misses, reprime=%s\n", + effective_iters, no_reprime ? "off" : "on"); + std::printf("%-50s %12s %12s %12s\n", "capture", "cycles_med", "insns_med", "br_miss_med"); + for (size_t fi = 0; fi < records.size(); ++fi) + { + const auto& rec = records[fi]; + const auto samples = recompiler_tests::BenchJit(rec, effective_iters, 0, !no_reprime); + if (samples.size() < 2) + { + std::printf("%-50s bench unavailable (PMU open failed?)\n", names[fi].c_str()); + continue; + } + std::vector cycles, insns, brmiss; + for (size_t i = 1; i < samples.size(); ++i) // skip warmup + { + cycles.push_back(samples[i][PmuCounters::CpuCycles]); + insns.push_back(samples[i][PmuCounters::InstructionsRetired]); + brmiss.push_back(samples[i][PmuCounters::BranchMisses]); + } + const Stat sc = Summarize(cycles); + const Stat si = Summarize(insns); + const Stat sb = Summarize(brmiss); + std::printf("%-50s %12llu %12llu %12llu (mad %llu/%llu/%llu)\n", + names[fi].c_str(), + (unsigned long long)sc.median, + (unsigned long long)si.median, + (unsigned long long)sb.median, + (unsigned long long)sc.mad, + (unsigned long long)si.mad, + (unsigned long long)sb.mad); + } + return 0; +} + +// Clamp/NaN by-design classifier. mVU broadcast FMACs leave operands unclamped, +// so the JIT can produce a raw NaN/Inf (exponent all-ones) where the +// interpreter's vuDouble clamps the operand first and then computes a large +// FINITE result — typically max-exponent (0xFE), e.g. 0xff7ffffe, NOT exactly +// ±FLT_MAX. mVUclamp1 also sign-strips NaN, so two NaN lanes can differ only in +// sign/payload. This whole family is shared with the x86 path and is NOT a bug. +// +// The signature is that the diverging lane is an *extreme saturation value* on +// at least one side: NaN/Inf (exponent all-ones), OR exactly ±maxfloat +// (0x?f7fffff) — the literal mVUclamp1 saturation output. The latter case +// surfaces when a NaN *operand* (which both engines hold identically) is +// consumed by a broadcast FMAC: the JIT clamps the NaN result to ±maxfloat +// while the interp's vuDouble path lands elsewhere (often 0). The result lanes +// are then both finite (e.g. JIT=0xff7fffff vs INTERP=0x0), so a NaN/Inf-only +// test misses it — but JIT==±maxfloat is the tell. Both-finite-and- +// neither-saturated is a genuine arithmetic divergence and stays real. +// +// A divergence is "clamp-only" iff every value-diff line is a vf/acc float lane +// matching this pattern, there is at least one such lane, and there are NO +// integer (viN), memory, or pipeline diffs. A single non-clamp diff (the +// FCGET→vi buckets, an integer op) keeps the whole divergence real. +static bool IsClampNanLane(u32 jit, u32 interp) +{ + // Signed-zero: both sides are zero, differing only in the sign bit. mVU + // flushes denormals via the hardware FPCR FZ bit while the interp's vuDouble + // flushes in software, so a denormal-underflow MUL/ADD lands at +0 vs -0 — + // arithmetically equal, a cosmetic sign-of-zero difference shared with x86 + // (same family as the documented NaN-sign divergence). + if ((jit & 0x7fffffffu) == 0 && (interp & 0x7fffffffu) == 0) + return true; + auto isExtreme = [](u32 v) { + if ((v & 0x7f800000u) == 0x7f800000u) return true; // NaN or Inf + if ((v & 0x7fffffffu) == 0x7f7fffffu) return true; // ±maxfloat (mVUclamp1) + return false; + }; + // CONSERVATIVE: only the JIT side being the saturated extreme is the + // mVU-clamp / unclamped-broadcast signature (mVU is the clamper). If the + // JIT produced a clean finite non-maxfloat value while the interp is the + // extreme, that is NOT the by-design pattern — it is the cleanest possible + // real-bug signature (the JIT computed something wrong) and must stay in the + // genuine queue. The one exception is both-sides-NaN/Inf, which is the + // documented mVUclamp1 NaN sign/payload-strip divergence. + if (isExtreme(jit)) + return true; + const bool jit_naninf = (jit & 0x7f800000u) == 0x7f800000u; + const bool int_naninf = (interp & 0x7f800000u) == 0x7f800000u; + return jit_naninf && int_naninf; // both NaN/Inf → clamp1 sign-strip +} + +// Returns true if all architectural diffs are the by-design clamp/NaN family. +// diff_lines come from DiffVu: value lines are ": JIT=0x.. INTERP=0x..". +// Q-disagree/P-disagree informational lines (appended later, no "JIT=0x") are +// ignored. Returns false if there are no value diffs at all. +static bool ClassifyClampOnly(const std::vector& diff_lines) +{ + bool saw_clamp_lane = false; + for (const auto& line : diff_lines) + { + const size_t jpos = line.find("JIT=0x"); + const size_t ipos = line.find("INTERP=0x"); + if (jpos == std::string::npos || ipos == std::string::npos) + continue; // informational line (Q-disagree etc.) — skip + // Only vf/acc float lanes can be clamp-pattern. ACC renders as "vf-1.*". + const bool is_vf = line.rfind("vf", 0) == 0; + if (!is_vf) + return false; // viN / vumem / pipeline diff → genuine bug + const u32 jit = static_cast(std::strtoul(line.c_str() + jpos + 6, nullptr, 16)); + const u32 interp = static_cast(std::strtoul(line.c_str() + ipos + 9, nullptr, 16)); + if (!IsClampNanLane(jit, interp)) + return false; // a vf lane that isn't the clamp pattern → genuine bug + saw_clamp_lane = true; + } + return saw_clamp_lane; +} + +// vudivtrace driver. For each capture: enable divtrace mode, replay (which +// runs JIT then interp under divtrace, populating per-op snapshot streams), +// then walk the streams to find the FIRST op where JIT and interp disagree +// on architectural state. Print microvu PC + decoded mnemonic + field diffs. +int RunDivTrace(const std::vector& records, + const std::vector& names, + u32 cycle_override) +{ + int diverged_files = 0; + for (size_t fi = 0; fi < records.size(); ++fi) + { + const auto& rec = records[fi]; + const int idx = static_cast(rec.vu_index); + std::printf("[divtrace] %s (vu%d start_pc=0x%08X cycles=%u)\n", + names[fi].c_str(), idx, rec.start_pc, rec.cycle_budget); + + // VIs the JIT defers writeback on (flag pipeline + TPC): STATUS=16, + // MAC=17, CLIP=18, TPC=26. flushAll doesn't push these. Q (vi22) and + // P (vi23) round-trip through current/pending lanes. The fingerprint + // (FingerprintRegs) and DiffVu both ignore exactly this set so a + // flagged divergence is real architectural state, not flag/Q/P noise. + const std::vector ignored_vi = {16, 17, 18, 22, 23, 26}; + + // Pass 1 — fingerprints only (zero-length full-snapshot window). This + // scales to multi-million-op vumain loops where the 3 KB/op full + // StateSnap stream overflows. + mvu_divtrace::EnterMode(idx); + mvu_divtrace::ConfigureFullWindow(0, 0); + const auto r = recompiler_tests::ReplayCapture(rec, + recompiler_tests::VuDiffMode::PipelinePermissive, cycle_override); + const u32 jit_count = mvu_divtrace::g_jit_snap_idx.load(std::memory_order_acquire); + const u32 interp_count = mvu_divtrace::g_interp_op_idx; + const u32 meta_count = static_cast(mvu_divtrace::g_meta.size()); + + if (!r.ok) + { + std::printf(" replay setup failed\n"); + mvu_divtrace::ExitMode(); + ++diverged_files; + continue; + } + + std::printf(" ops compiled: %u jit-executed: %u interp-executed: %u\n", + meta_count, jit_count, interp_count); + const char* term = r.interp_ebit ? "ebit" : "budget"; + std::printf(" term=%s\n", term); + + // Scan fingerprints for the first divergent execution index (and up to + // 40 divergent indices for the pattern summary). + const u32 cmp_n = std::min(jit_count, interp_count); + u32 first_diff = cmp_n; + std::vector divergent_idx; + for (u32 i = 0; i < cmp_n; ++i) + { + if (mvu_divtrace::g_jit_fps[i] != mvu_divtrace::g_interp_fps[i]) + { + if (first_diff == cmp_n) + first_diff = i; + divergent_idx.push_back(i); + if (divergent_idx.size() >= 40) break; + } + } + + if (first_diff == cmp_n) + { + // The entire common prefix matched architecturally. The two sides + // only differ in HOW MANY ops they ran. Classify that delta: + // + // - counts equal → fully clean. + // - counts differ, interp stopped on the CYCLE BUDGET (not E-bit), + // and the JIT ran at least as far → benign block-boundary + // overshoot. VU0 runs in EE-driven partial chunks, so every VU0 + // capture is budget-truncated mid-stream; the interp stops at the + // exact cycle while the JIT can only stop at its next atomic block + // boundary, so it runs a few extra ops of the SAME control flow + // interp would have continued into. Not a bug — this is the + // dominant VU0 false positive. + // + // Anything else with a clean prefix IS a real divergence: interp hit + // an E-bit terminator the JIT ran past (missed terminator), or the + // JIT stopped earlier than interp (premature terminator). Fall + // through to the COUNT_MISMATCH report. + if (jit_count == interp_count) + { + std::printf(" ok (%u ops matched architecturally)\n", cmp_n); + mvu_divtrace::ExitMode(); + continue; + } + const bool interp_budget_truncated = !r.interp_ebit; + if (interp_budget_truncated && jit_count >= interp_count) + { + std::printf(" ok (%u common ops matched; +%u JIT op(s) past the " + "interp budget cutoff — benign block-boundary overshoot)\n", + cmp_n, jit_count - interp_count); + mvu_divtrace::ExitMode(); + continue; + } + } + + ++diverged_files; + std::vector diff_lines; + if (first_diff < cmp_n) + { + // Pass 2 — re-run with a small full-snapshot window around the + // first divergence so the detailed report has real register state. + // Window length is CTX+TAIL+1 regardless of program size (it's + // [first_diff-CTX, first_diff+TAIL]), so a large CTX is cheap and + // lets the snapshot-scan below catch roots the fingerprint localizer + // mis-reports. + const u32 CTX = 1024, TAIL = 8; + const u32 wlo = first_diff > CTX ? first_diff - CTX : 0; + const u32 wlen = (first_diff - wlo) + 1 + TAIL; + mvu_divtrace::Reset(); + mvu_divtrace::ConfigureFullWindow(wlo, wlen); + recompiler_tests::ReplayCapture(rec, + recompiler_tests::VuDiffMode::PipelinePermissive, cycle_override); + mvu_divtrace::ExitMode(); + + auto JS = [&](u32 g) -> const mvu_divtrace::StateSnap& { + return mvu_divtrace::g_jit_snaps[g - wlo]; }; + auto ISn = [&](u32 g) -> const mvu_divtrace::StateSnap& { + return mvu_divtrace::g_interp_snaps[g - wlo]; }; + + const auto& js = JS(first_diff); + const auto& is = ISn(first_diff); + + // Recompute the actual field diffs at the first divergent op. + recompiler_tests::VuSnapshot jsnap, isnap; + jsnap.index = idx; jsnap.regs = js.regs; + isnap.index = idx; isnap.regs = is.regs; + diff_lines = recompiler_tests::DiffVu(jsnap, isnap, + recompiler_tests::VuDiffMode::PipelinePermissive, ignored_vi); + // Q/P multiset info (appended — doesn't drive grouping, the + // fingerprint already excluded Q/P). + { + const u32 ji_q = js.regs.VI[REG_Q].UL, ji_pq = js.regs.pending_q; + const u32 in_q = is.regs.VI[REG_Q].UL; + if (in_q != ji_q && in_q != ji_pq) + { + char line[160]; + std::snprintf(line, sizeof(line), + "Q-disagree: INT=%08x not in JIT{%08x,%08x}", + in_q, ji_q, ji_pq); + diff_lines.push_back(line); + } + if (idx == 1) + { + const u32 ji_p = js.regs.VI[REG_P].UL, ji_pp = js.regs.pending_p; + const u32 in_p = is.regs.VI[REG_P].UL; + if (in_p != ji_p && in_p != ji_pp) + { + char line[160]; + std::snprintf(line, sizeof(line), + "P-disagree: INT=%08x not in JIT{%08x,%08x}", + in_p, ji_p, ji_pp); + diff_lines.push_back(line); + } + } + } + + std::printf("\n === FIRST DIVERGENCE at op #%u ===\n", first_diff); + std::printf(" JIT meta_idx=%u pre_xPC=0x%04X\n", js.meta_idx, js.pre_xPC); + std::printf(" INT meta_idx=%u pre_xPC=0x%04X\n", is.meta_idx, is.pre_xPC); + if (js.pre_xPC != is.pre_xPC) + { + std::printf(" ⚠ pre_xPC mismatch — control-flow divergence;\n"); + std::printf(" JIT and interp executed different ops at this step.\n"); + } + + const u32 microvu_pc = is.pre_xPC; // interp's xPC is authoritative + const u32 prog_size = static_cast(rec.microcode.size()); + const u32 limit = (idx == 0) ? 0xFFFu : 0x3FFFu; + const u32 wrapped = microvu_pc & limit; + // Mnemonics for the machine-readable [group] line below. First + // whitespace-delimited token only (no operands) so the same op + // buckets identically across programs. + std::string grp_upper = "?", grp_lower = "?"; + int up_fs = -1, up_ft = -1, up_bc = -1; + if (wrapped + 8 <= prog_size) + { + u32 lo = 0, up = 0; + std::memcpy(&lo, rec.microcode.data() + wrapped + 0, 4); + std::memcpy(&up, rec.microcode.data() + wrapped + 4, 4); + up_fs = (up >> 11) & 0x1F; + up_ft = (up >> 16) & 0x1F; + up_bc = up & 0x3; + // disVU?Micro?F share a static output buffer, so copy upper's + // result before calling the lower decoder. + const char* upper_s = (idx == 0) + ? disVU0MicroUF(up, microvu_pc + 4) : disVU1MicroUF(up, microvu_pc + 4); + const std::string upper_copy = upper_s ? upper_s : "?"; + const char* lower_s = (idx == 0) + ? disVU0MicroLF(lo, microvu_pc) : disVU1MicroLF(lo, microvu_pc); + std::printf(" microcode: %08X %08X\n", up, lo); + std::printf(" upper: %s\n", upper_copy.c_str()); + std::printf(" lower: %s\n", lower_s ? lower_s : "?"); + + // Disasm format is " : " — the + // mnemonic is the first token after ": ". Stop at space/comma. + auto mnemonic = [](const std::string& s) -> std::string { + size_t c = s.find(": "); + size_t start = (c == std::string::npos) ? 0 : c + 2; + while (start < s.size() && (s[start] == ' ' || s[start] == '\t')) + ++start; + size_t e = start; + while (e < s.size() && s[e] != ' ' && s[e] != '\t' && s[e] != ',') + ++e; + return e > start ? s.substr(start, e - start) : s; + }; + grp_upper = mnemonic(upper_copy); + if (lower_s) + grp_lower = mnemonic(lower_s); + } + + // field "class": leading prefix of the first diff line up to the + // first '.', ':', '[', '-', or digit (vf12.x→vf, vi02→vi, + // Q-disagree→Q, mem[..]→mem). ACC renders as "vf-1.x" — special- + // cased to "acc" — so cross-program diffs on the same register + // class collapse into one bucket. + std::string grp_field = "?"; + if (!diff_lines.empty()) + { + const std::string& fl = diff_lines.front(); + if (fl.rfind("vf-1", 0) == 0) + { + grp_field = "acc"; + } + else + { + size_t e = 0; + while (e < fl.size() && fl[e] != '.' && fl[e] != ':' + && fl[e] != '[' && fl[e] != '-' + && !(fl[e] >= '0' && fl[e] <= '9')) + ++e; + grp_field = e ? fl.substr(0, e) : fl; + } + } + + // By-design clamp/NaN family detector — strips the documented + // mVU-FMAC NaN↔±FLT_MAX divergence off the real-bug queue. + const bool clamp_only = ClassifyClampOnly(diff_lines); + + // Machine-readable grouping key for triage.py --group. One line + // per diverging capture; bucket by (vu, mnem, field, term, clamp). + std::printf(" [group] vu=%d mnem=%s/%s field=%s term=%s clamp=%d\n", + idx, grp_upper.c_str(), grp_lower.c_str(), grp_field.c_str(), + term, clamp_only ? 1 : 0); + + std::printf("\n state diff (PipelinePermissive):\n"); + for (const auto& d : diff_lines) + std::printf(" %s\n", d.c_str()); + + // Show 5 ops of pre-divergence context with vi01/vi04/vi06/vi07 + // (commonly tested by IBxxx branches, so useful for spotting the + // branch input that decided the split). + std::printf("\n pre-divergence context (5 ops + key VIs):\n"); + const u32 ctx_start = first_diff >= 5 ? first_diff - 5 : 0; + for (u32 i = ctx_start; i < first_diff; ++i) + { + const auto& jss = JS(i); + const auto& iss = ISn(i); + std::printf(" op #%-4u xPC=0x%04X JIT vi01=%04x vi04=%04x vi06=%04x vi07=%04x INT vi01=%04x vi04=%04x vi06=%04x vi07=%04x\n", + i, jss.pre_xPC, + jss.regs.VI[1].UL & 0xFFFF, jss.regs.VI[4].UL & 0xFFFF, + jss.regs.VI[6].UL & 0xFFFF, jss.regs.VI[7].UL & 0xFFFF, + iss.regs.VI[1].UL & 0xFFFF, iss.regs.VI[4].UL & 0xFFFF, + iss.regs.VI[6].UL & 0xFFFF, iss.regs.VI[7].UL & 0xFFFF); + } + + // Print vf00 + vf01 (DIV inputs in the 0x408 case) and Q lanes. + std::printf("\n context (vf00, vf01, Q+pending_q):\n"); + std::printf(" JIT vf00.w=%08x vf01.w=%08x Q=%08x pendQ=%08x\n", + js.regs.VF[0].UL[3], js.regs.VF[1].UL[3], + js.regs.VI[REG_Q].UL, js.regs.pending_q); + std::printf(" INT vf00.w=%08x vf01.w=%08x Q=%08x pendQ=%08x\n", + is.regs.VF[0].UL[3], is.regs.VF[1].UL[3], + is.regs.VI[REG_Q].UL, is.regs.pending_q); + + // If the first divergence is on a VF reg, dump that VF's full + // pre-op and post-op state on both sides — the JIT/interp PRE + // values reveal whether divergence is bad input vs bad emit. + if (!diff_lines.empty() && diff_lines.front().rfind("vf", 0) == 0) + { + const std::string& fl = diff_lines.front(); + size_t dot = fl.find('.'); + const std::string num = fl.substr(2, (dot == std::string::npos ? fl.size() : dot) - 2); + char* end = nullptr; + const long n = std::strtol(num.c_str(), &end, 10); + if (end != num.c_str() && n >= 0 && n < 32) + { + std::printf("\n diverging VF%ld lanes (xyzw):\n", n); + if (first_diff > 0) + { + const auto& jp = JS(first_diff - 1); + const auto& ip = ISn(first_diff - 1); + std::printf(" JIT pre : %08x %08x %08x %08x\n", + jp.regs.VF[n].UL[0], jp.regs.VF[n].UL[1], + jp.regs.VF[n].UL[2], jp.regs.VF[n].UL[3]); + std::printf(" INT pre : %08x %08x %08x %08x\n", + ip.regs.VF[n].UL[0], ip.regs.VF[n].UL[1], + ip.regs.VF[n].UL[2], ip.regs.VF[n].UL[3]); + } + std::printf(" JIT post : %08x %08x %08x %08x\n", + js.regs.VF[n].UL[0], js.regs.VF[n].UL[1], + js.regs.VF[n].UL[2], js.regs.VF[n].UL[3]); + std::printf(" INT post : %08x %08x %08x %08x\n", + is.regs.VF[n].UL[0], is.regs.VF[n].UL[1], + is.regs.VF[n].UL[2], is.regs.VF[n].UL[3]); + } + } + + // ACC divergence (rendered "vf-1.*"): the VF-lane dump above guards + // n>=0 and skips ACC, so dump ACC pre/post here plus the op's runtime + // Fs/Ft operands. ACC is fingerprint-tracked, so at the FIRST + // divergence ACC pre MUST agree — a post-only ACC divergence with + // JIT=±maxfloat (0xff7fffff / 0x7f7fffff) is the documented + // broadcast-unclamped overflow→clamp family, not a new emit bug. + if (!diff_lines.empty() && diff_lines.front().rfind("vf-1", 0) == 0) + { + std::printf("\n diverging ACC lanes (xyzw):\n"); + if (first_diff > 0) + { + const auto& jp = JS(first_diff - 1); + const auto& ip = ISn(first_diff - 1); + std::printf(" JIT pre : %08x %08x %08x %08x\n", + jp.regs.ACC.UL[0], jp.regs.ACC.UL[1], jp.regs.ACC.UL[2], jp.regs.ACC.UL[3]); + std::printf(" INT pre : %08x %08x %08x %08x\n", + ip.regs.ACC.UL[0], ip.regs.ACC.UL[1], ip.regs.ACC.UL[2], ip.regs.ACC.UL[3]); + auto dumpVf = [&](const char* who, const VURegs& r, int v) { + if (v >= 0 && v < 32) + std::printf(" %s vf%02d : %08x %08x %08x %08x\n", who, v, + r.VF[v].UL[0], r.VF[v].UL[1], r.VF[v].UL[2], r.VF[v].UL[3]); + }; + std::printf(" op operands (pre): Fs=vf%d Ft=vf%d bc=%c\n", + up_fs, up_ft, "xyzw"[up_bc & 3]); + dumpVf("JIT Fs", jp.regs, up_fs); + dumpVf("INT Fs", ip.regs, up_fs); + dumpVf("JIT Ft", jp.regs, up_ft); + dumpVf("INT Ft", ip.regs, up_ft); + } + std::printf(" JIT post : %08x %08x %08x %08x\n", + js.regs.ACC.UL[0], js.regs.ACC.UL[1], js.regs.ACC.UL[2], js.regs.ACC.UL[3]); + std::printf(" INT post : %08x %08x %08x %08x\n", + is.regs.ACC.UL[0], is.regs.ACC.UL[1], is.regs.ACC.UL[2], is.regs.ACC.UL[3]); + } + + // Integer (viN) divergence: dump VI[0..15] pre (op N-1) and post + // (op N) on both sides — shows which integer reg split and whether it + // split AT this op (emit bug) or was already diverged in the pre-snap + // (integer-pipeline / snapshot-alignment artifact). + if (!diff_lines.empty() && diff_lines.front().rfind("vi", 0) == 0 + && diff_lines.front().rfind("vf", 0) != 0) + { + auto dumpVI = [](const char* who, const VURegs& r) { + std::printf(" %s:", who); + for (int v = 0; v < 16; ++v) + std::printf(" %x=%04x", v, r.VI[v].UL & 0xFFFF); + std::printf("\n"); + }; + std::printf("\n VI[0..15] pre (op #%u) / post (op #%u):\n", + first_diff > 0 ? first_diff - 1 : 0, first_diff); + if (first_diff > 0) + { + dumpVI("JIT pre ", JS(first_diff - 1).regs); + dumpVI("INT pre ", ISn(first_diff - 1).regs); + } + dumpVI("JIT post", js.regs); + dumpVI("INT post", is.regs); + + // Trace the single diverging integer reg across the whole window + // to see if it RECONVERGES (integer-pipeline-boundary artifact) or + // PROPAGATES (genuine hazard/emit bug). + // Snapshot-based first-divergence over ALL VI[0..15] within the + // window. The fingerprint first_diff can mis-localize badly when + // the JIT/interp op streams desync (count mismatch) — the + // fingerprint may report a downstream op while the true integer + // root is much earlier. This scan walks the captured window from + // its start and reports the earliest VI split, with the writing + // op's microcode so the root op is identifiable. + std::printf("\n snapshot-scan: earliest VI[0..15] split in window:\n"); + bool found = false; + for (u32 i = wlo; i < wlo + wlen && !found; ++i) + for (int v = 0; v < 16; ++v) + if ((JS(i).regs.VI[v].UL & 0xFFFF) != (ISn(i).regs.VI[v].UL & 0xFFFF)) + { + const u32 xpc = ISn(i).pre_xPC; + const u32 w = xpc & ((idx == 0) ? 0xFFFu : 0x3FFFu); + u32 lw = 0; + if (w + 4 <= rec.microcode.size()) + std::memcpy(&lw, rec.microcode.data() + w, 4); + std::printf(" op #%u xPC=0x%04X vi%d: JIT=%04x INT=%04x (lower=0x%08x)\n", + i, xpc, v, JS(i).regs.VI[v].UL & 0xFFFF, + ISn(i).regs.VI[v].UL & 0xFFFF, lw); + found = true; + break; + } + if (!found) + std::printf(" (no VI split inside the %u-op window — root is further back)\n", wlen); + } + + if (divergent_idx.size() > 1) + { + std::printf("\n divergent ops summary (first %zu of the " + "fingerprint stream; xPC only — out of detail window):\n", + divergent_idx.size()); + for (u32 g : divergent_idx) + std::printf(" op #%-7u xPC=0x%04X%s\n", g, + mvu_divtrace::g_interp_xpc[g], + g == first_diff ? " <= first" : ""); + } + } + else + { + mvu_divtrace::ExitMode(); + // Reached only for a GENUINE terminator divergence: the common + // prefix is architecturally clean, but the count delta is NOT the + // benign budget-overshoot filtered above. Either interp hit an E-bit + // the JIT ran past, or the JIT terminated before interp did. + const char* kind = (jit_count > interp_count) + ? "JIT RAN PAST interp's terminator (missed E-bit / over-run)" + : "JIT STOPPED BEFORE interp (premature terminator)"; + std::printf(" [group] vu=%d mnem=TERMINATOR_DIVERGENCE/- field=count term=%s clamp=0\n", + idx, term); + std::printf("\n ⚠ TERMINATOR DIVERGENCE — clean common prefix (%u ops), but\n", cmp_n); + std::printf(" counts disagree (jit=%u interp=%u, interp_term=%s): %s.\n", + jit_count, interp_count, term, kind); + std::printf(" The divergence is the terminator/branch at op #%u; inspect it.\n", + cmp_n); + } + std::printf("\n"); + } + if (diverged_files) + std::printf("[divtrace] %d of %zu captures diverged\n", + diverged_files, records.size()); + return diverged_files == 0 ? 0 : 5; +} + + +// Print the process-layout anchors the persisted-JIT program cache depends +// on. In a non-PIE build with the fixed-base arena reservation, every line +// must be identical across runs (the determinism gate); the image anchor is +// a libpcsx2 .text address standing in for every baked C-helper/global +// reference, the arena lines cover the rec slabs the cached code lives in. +int RunPrintBases() +{ + std::printf("[print-bases] image_anchor %p (SysMemory::GetDataPtr)\n", + reinterpret_cast(&SysMemory::GetDataPtr)); + std::printf("[print-bases] data_arena %p\n", SysMemory::GetDataPtr(0)); + std::printf("[print-bases] code_arena %p\n", SysMemory::GetCodePtr(0)); + std::printf("[print-bases] vu0_rec %p\n", SysMemory::GetVU0Rec()); + std::printf("[print-bases] vu1_rec %p\n", SysMemory::GetVU1Rec()); + std::printf("[print-bases] vu_mem %p\n", SysMemory::GetVUMem()); + return 0; +} + +int RunDumpAsm(const std::vector& records, + const std::vector& names) +{ + int failures = 0; + for (size_t fi = 0; fi < records.size(); ++fi) + { + const std::string out_path = names[fi] + ".codegen.s"; + const bool ok = recompiler_tests::DumpJitAsm(records[fi], out_path); + std::printf("[dump-asm] %s -> %s: %s\n", + names[fi].c_str(), out_path.c_str(), ok ? "ok" : "FAILED"); + if (!ok) + ++failures; + } + return failures == 0 ? 0 : 4; +} + +} // namespace + +int main(int argc, char** argv) +{ + Options opts; + if (!ParseArgs(argc, argv, opts)) + { + PrintUsage(argv[0]); + return 1; + } + + if (opts.no_progcache) + mVUPersist::SetProcessDisable(true); + + if (!opts.cache_dir.empty()) + { + // Must be wired before Initialize: mVUinit runs the ProgCache VERSION + // handshake against EmuFolders::Cache, and recording changes emitted + // code forms from the very first compile. The config bool is what + // production gates the whole feature on (vurunner doesn't go through + // VMManager, so set it directly); SetRecordingEnabled mixes the + // recording state into the options sentinel, so a --cache-dir run + // can never collide with a recording-off cache of the same programs. + if (mVUPersist::IsProcessDisabled()) + { + std::fprintf(stderr, "vurunner: --cache-dir ignored — " + "--no-progcache is set\n"); + } + else + { + EmuFolders::Cache = opts.cache_dir; + EmuConfig.Cpu.Recompiler.EnableVUProgramCache = true; + mVUPersist::SetRecordingEnabled(true); + } + } + + if (!recompiler_tests::RecompilerTestEnvironment::Initialize()) + { + std::fprintf(stderr, "vurunner: RecompilerTestEnvironment::Initialize failed\n"); + return 3; + } + + if (opts.print_bases) + { + const int rc = RunPrintBases(); + if (opts.files.empty()) + { + recompiler_tests::RecompilerTestEnvironment::Shutdown(); + return rc; + } + } + + std::vector records; + std::vector names; + records.reserve(opts.files.size()); + names.reserve(opts.files.size()); + for (const auto& path : opts.files) + { + vu_capture::CaptureRecord rec; + if (!vu_capture::ReadFromFile(path, rec)) + { + std::fprintf(stderr, "vurunner: failed to read %s (bad magic/version/sizes?)\n", + path.c_str()); + recompiler_tests::RecompilerTestEnvironment::Shutdown(); + return 1; + } + records.push_back(std::move(rec)); + names.push_back(path); + } + + int exit_code = 0; + if (opts.dump_microcode) + exit_code |= RunDumpMicrocode(records, names, opts.dump_count); + if (opts.diff) + exit_code |= RunDiff(records, names, opts.iters, opts.cycle_override); + if (opts.bench) + exit_code |= RunBench(records, names, opts.iters, opts.bench_no_reprime); + if (opts.dump_asm) + exit_code |= RunDumpAsm(records, names); + if (opts.divtrace) + exit_code |= RunDivTrace(records, names, opts.cycle_override); + + if (!opts.cache_dir.empty()) + { + // Flush still-live programs to disk so their saves show in the + // summary (mirrors the mVUclose-side save Shutdown would do). + recompiler_tests::RecompilerTestEnvironment::ResetVuBlockCache(0); + recompiler_tests::RecompilerTestEnvironment::ResetVuBlockCache(1); + for (u32 vu = 0; vu < 2; vu++) + { + const auto c = mVUProgCache::GetStats(vu); + const auto p = mVUPersist::GetStats(vu); + std::printf("[cache] vu%u: entries=%llu payloadWrites=%llu payloadHits=%llu " + "payloadMissing=%llu payloadRejects=%llu preloaded=%llu preloadHits=%llu " + "programsHydrated=%llu blocksHydrated=%llu chunksRecorded=%llu " + "chunksDropped=%llu blockCompiles=%llu\n", + vu, + (unsigned long long)c.entries, + (unsigned long long)c.payloadWrites, + (unsigned long long)c.payloadHits, + (unsigned long long)c.payloadMissing, + (unsigned long long)c.payloadRejects, + (unsigned long long)c.preloadedPayloads, + (unsigned long long)c.preloadHits, + (unsigned long long)p.programsHydrated, + (unsigned long long)p.blocksHydrated, + (unsigned long long)p.chunksRecorded, + (unsigned long long)p.chunksDropped, + (unsigned long long)mVUPersist::GetBlockCompileCount(vu)); + } + } + + recompiler_tests::RecompilerTestEnvironment::Shutdown(); + return exit_code; +} diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index c0dc49c3d3..9c11c73987 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -3617,6 +3617,19 @@ void VMManager::SetHardwareDependentDefaultSettings(SettingsInterface& si) const int extra_threads = (core_count > 3) ? 3 : 2; Console.WriteLn(fmt::format(" Setting Extra Software Rendering Threads to {}.", extra_threads)); si.SetIntValue("EmuCore/GS", "extrathreads", extra_threads); + + // Enable thread pinning by default on heterogeneous CPUs (big.LITTLE). + // Without it, the kernel may migrate the EE / VU / GS threads to E-cores + // mid-frame — even one such migration is enough to miss the 60fps deadline + // on Apple Silicon under Asahi and Intel Alder Lake-class hybrid systems. + // SetEmuThreadAffinities already sorts processors by frequency, so pinning + // to indices 0..2 lands on the fastest cores. + if (cpuinfo_get_clusters_count() > 1 && core_count >= 3) + { + Console.WriteLn(fmt::format(" Heterogeneous CPU detected ({} clusters); enabling thread pinning.", + cpuinfo_get_clusters_count())); + si.SetBoolValue("EmuCore", "EnableThreadPinning", true); + } } #elif defined(__APPLE__)