mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
EERUNNER_EXITSTORM=<period_us> spawns a thread that fires Cpu->ExitExecution() at randomized intervals during a liverun, mimicking the Android JNI pause/suspend churn (native-lib calls it cross-thread against a running EE). This is what reproduced the God of War II poisoned-timer trigger on a desktop within 1500 frames, and what verified the fix clean over a denser 3000-frame storm. EERUNNER_EECYCLESKIP / EERUNNER_EECYCLERATE apply the EE cycle speedhacks so a handheld's clock shape (the Android Low-End preset ships cycle skip 1) is reproducible on the desk.
4495 lines
179 KiB
C++
4495 lines
179 KiB
C++
// SPDX-FileCopyrightText: 2026 yaps2 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 <algorithm>
|
||
#include <atomic>
|
||
#include <chrono>
|
||
#include <csignal>
|
||
#include <map>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <condition_variable>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <mutex>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <thread>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
#ifdef __linux__
|
||
#include <linux/perf_event.h>
|
||
#include <sys/ioctl.h>
|
||
#include <sys/syscall.h>
|
||
#include <unistd.h>
|
||
#endif
|
||
|
||
#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/Perf.h"
|
||
#include "common/ProgressCallback.h"
|
||
#include "common/SettingsWrapper.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/MTGS.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/VUmicro.h"
|
||
|
||
// --statereport decode targets
|
||
#include "pcsx2/CDVD/CDVD.h"
|
||
#include "pcsx2/Counters.h"
|
||
#include "pcsx2/Gif_Unit.h"
|
||
#include "pcsx2/IopCounters.h"
|
||
#include "pcsx2/MTVU.h"
|
||
#include "pcsx2/R3000A.h"
|
||
#include "pcsx2/Vif_Dma.h"
|
||
|
||
#include "pcsx2/ee_divtrace.h"
|
||
|
||
#if defined(ARCH_ARM64)
|
||
#include "pcsx2/arm64/iR5900-arm64.h"
|
||
#endif
|
||
|
||
#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,
|
||
StateReport,
|
||
TwinDump,
|
||
TwinCompare,
|
||
DumpConfig,
|
||
MkState,
|
||
};
|
||
|
||
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 bool s_renderer_explicit = false; // user passed --renderer (an explicit null is honored in liverun)
|
||
static std::string s_memdump_prefix; // --memdump <prefix>: write <prefix>.{interp,jit}.bin at the last frame
|
||
static bool s_perf_jitdump = false; // --perf-jitdump: emit Linux perf jitdump for `perf inject --jit` (profiling)
|
||
|
||
// --gsdump: headless GS-dump capture from a --liverun. A .gs dump is a recording of
|
||
// the GIF command stream, so replaying one in pcsx2-gsrunner exercises the GS with
|
||
// ZERO emulator in front of it — which is the only way to A/B the GS across two
|
||
// builds honestly. Measuring the GS inside a live run cannot work: the MTGS ring and
|
||
// the SW rasterizer's job queue both spin while waiting, so a build with a faster EE
|
||
// changes the GS threads' instruction counts without changing a single pixel of GS
|
||
// work. Capture once here, then replay the same dump on both builds.
|
||
static std::string s_gsdump_path; // --gsdump <path.png>: dump basename (extension replaced)
|
||
static uint32_t s_gsdump_frames = 0; // number of frames to record
|
||
static uint32_t s_gsdump_at = 30; // --gsdump-at F: start recording after frame F
|
||
|
||
// twindiff (cross-BUILD divergence finder) state — see the big comment above
|
||
// RunTwinDump. The same byte-identical runner is built in two trees (working
|
||
// vs broken); one dumps a trace, the other compares against it.
|
||
static std::string s_twin_prefix; // --twindump <prefix>: trace + ram-dump output prefix
|
||
static std::string s_twin_other; // --twincompare <file>: the other build's .twin trace
|
||
static std::string s_twin_label = "local"; // --twin-label <name>: stamped into the trace header
|
||
static int s_twin_ram_at = -1; // --twin-ram-at <frame>: raw EE RAM+scratch dump at this frame
|
||
static std::string s_dumpcfg_path; // --dump-config <path>: effective-EmuConfig INI dump
|
||
static std::string s_mkstate_path; // --mkstate <out.p2s>: write a savestate after --frames
|
||
struct SetOverride
|
||
{
|
||
std::string section, key, value;
|
||
};
|
||
static std::vector<SetOverride> s_set_overrides; // --set Section/Key=Value (repeatable)
|
||
static u32 s_rec_fallback_groups = 0; // --rec-fallback <groups>: EE opcode groups forced to interp
|
||
#if defined(ARCH_ARM64)
|
||
static u32 s_rec_fallback_reg_masks[EERecFallback::kCop2MoveOpCount] = {~0u, ~0u, ~0u, ~0u}; // per-COP2-move-op register filters
|
||
static u64 s_rec_fallback_vu_mask[EERecFallback::kCop2VuIdCount / 64] = {~0ull, ~0ull, ~0ull, ~0ull}; // per-VU-macro-op filter
|
||
#endif
|
||
|
||
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<ImGuiManager::FontInfo> 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<std::mutex>& 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<ProgressCallback> 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<WindowInfo> 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<WindowInfo> 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<void()> 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
|
||
}
|
||
|
||
bool Host::HasNativeAchievementNotifications() { return false; }
|
||
void Host::OnAchievementNotification(const char*, float, const char*, const char*, const char*) {}
|
||
|
||
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<u32> InputManager::ConvertHostKeyboardStringToCode(const std::string_view str)
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
|
||
std::optional<std::string> 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<s32>(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 <file> --frames N [--iso <file>] [<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, " --rec-fallback <groups>: comma-separated EE opcode groups to force through the INTERPRETER\n");
|
||
std::fprintf(stderr, " instead of native codegen, with everything else still JIT. Bisects \"the EE JIT\n");
|
||
std::fprintf(stderr, " miscomputes something\" down to one emitter family without a rebuild per hypothesis:\n");
|
||
std::fprintf(stderr, " the group that makes the symptom disappear contains the bug. Groups: fpu, cop2,\n");
|
||
std::fprintf(stderr, " mmi, multdiv, shift, arith, loadstore, move, cop0, branch (plus all / none).\n");
|
||
std::fprintf(stderr, " cop2 narrows further into cop2move (QMFC2/CFC2/QMTC2/CTC2), cop2vu (the VU\n");
|
||
std::fprintf(stderr, " macro ops) and cop2ls (LQC2/SQC2). The four move ops take a ':<reg>' filter\n");
|
||
std::fprintf(stderr, " (ctc2:27, ctc2:16:21); cop2vu takes a ':<mnemonic>' one (cop2vu:vmaddaw,\n");
|
||
std::fprintf(stderr, " cop2vu:vmulax:vmaddaz) — cop2vu is ~100 emitters, so don't stop the funnel\n");
|
||
std::fprintf(stderr, " there. --mkstate prints a COP2VU CENSUS of the macro ops actually compiled;\n");
|
||
std::fprintf(stderr, " bisect over that list, since an op never compiled cannot be the bug.\n");
|
||
std::fprintf(stderr, " Pairs well with --mkstate --renderer sw for a visual oracle. arm64 only.\n");
|
||
std::fprintf(stderr, " --gsdump <path>[:<frames>]: with --liverun, record a .gs dump of the GIF stream (default 1\n");
|
||
std::fprintf(stderr, " frame) starting after --gsdump-at frames (default 30, so the scene has settled).\n");
|
||
std::fprintf(stderr, " Replaying that dump in pcsx2-gsrunner is the only honest way to A/B the GS across\n");
|
||
std::fprintf(stderr, " builds: it drives the GS with no emulator in front of it, so the MTGS ring and the\n");
|
||
std::fprintf(stderr, " SW job queue can't spin-wait a faster EE into a bogus GS instruction-count delta.\n");
|
||
std::fprintf(stderr, " --gsdump-at <frame>: frame after which --gsdump starts recording (default 30).\n");
|
||
std::fprintf(stderr, " --renderer <null|vk|ogl|sw>: 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, " --statereport: load the savestate, print a field-level decode of every serialized timebase\n");
|
||
std::fprintf(stderr, " (EE cycle/COP0 Count pair, rcnt bases, vsync phase, EE<->IOP skew, IOP counters,\n");
|
||
std::fprintf(stderr, " CDVD RTC, GIF paths, VIF, DMA regs, MTVU) and exit. Diff two states with:\n");
|
||
std::fprintf(stderr, " diff <(... --statereport --savestate A) <(... --statereport --savestate B)\n");
|
||
std::fprintf(stderr, " --savestate <file>: Savestate to load after Initialize (required).\n");
|
||
std::fprintf(stderr, " --frames N: Number of frames to run (default 300).\n");
|
||
std::fprintf(stderr, " --iso <file>: Game ISO/disc to mount (required so the savestate has its disc).\n");
|
||
std::fprintf(stderr, " --perf-jitdump: Emit a Linux perf jitdump (under EmuFolders::Cache) so `perf inject --jit`\n");
|
||
std::fprintf(stderr, " resolves EE_/VU0_/VU1_/IOP_/VIF_ JIT block symbols. Profiling only; with --liverun it\n");
|
||
std::fprintf(stderr, " also honors an explicit --renderer null. Requires a USE_PERF_JITDUMP build.\n");
|
||
std::fprintf(stderr, " --twindump <prefix>: twindiff golden side. Runs full-interp x2 (determinism control) + full-JIT\n");
|
||
std::fprintf(stderr, " free-running passes from the savestate, recording per-frame memhash/regfp/cycle to\n");
|
||
std::fprintf(stderr, " <prefix>.twin for a DIFFERENT build of this runner to compare against.\n");
|
||
std::fprintf(stderr, " --twincompare <file.twin>: twindiff test side. Runs the same three passes locally and diffs them\n");
|
||
std::fprintf(stderr, " against the other build's trace: cross-interp = shared-C++ drift; jit-vs-interp per\n");
|
||
std::fprintf(stderr, " tree; jit-vs-jit = end-to-end working-vs-broken. Exit 2 on real divergence.\n");
|
||
std::fprintf(stderr, " --twin-label <name>: label stamped into the .twin header (e.g. armsx2-master / jit-transplant).\n");
|
||
std::fprintf(stderr, " --twin-ram-at <frame>: also dump raw EE RAM+scratch at that frame (interp + jit passes) to\n");
|
||
std::fprintf(stderr, " <prefix>.f<N>.{interp,jit}.bin, for offline page/region diffing across builds.\n");
|
||
std::fprintf(stderr, " --dump-config <path>: boot far enough for GameDB to apply, write the EFFECTIVE EmuConfig INI,\n");
|
||
std::fprintf(stderr, " and exit. Diff the two trees' dumps FIRST - config drift is the cheapest bug class.\n");
|
||
std::fprintf(stderr, " --mkstate <out.p2s>: run --frames under the production JIT (from --savestate if given, else a\n");
|
||
std::fprintf(stderr, " cold disc boot) and write a savestate. Bootstrap twindiff states on the WORKING\n");
|
||
std::fprintf(stderr, " binary, positioned BEFORE the corrupting computation (e.g. before a level load).\n");
|
||
std::fprintf(stderr, " --set Section/Key=Value: base-layer settings override, applied after the harness pinning\n");
|
||
std::fprintf(stderr, " (repeatable). E.g. --set EmuCore/CPU/Recompiler/fpuFullMode=true. GameDB\n");
|
||
std::fprintf(stderr, " per-game keys still win; the twin passes own the EnableEE/VU0/VU1/IOP toggles.\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<bool>(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_PARAM("--gsdump"))
|
||
{
|
||
const std::string_view spec(argv[++i]);
|
||
const std::string_view::size_type at = spec.rfind(':');
|
||
std::string_view path = spec;
|
||
s_gsdump_frames = 1;
|
||
// "<path>:<frames>" — the colon is optional, and a Windows-style
|
||
// drive letter can't appear here, so rfind is unambiguous.
|
||
if (at != std::string_view::npos)
|
||
{
|
||
if (const std::optional<u32> n = StringUtil::FromChars<u32>(spec.substr(at + 1), 10))
|
||
{
|
||
s_gsdump_frames = std::max<u32>(1, n.value());
|
||
path = spec.substr(0, at);
|
||
}
|
||
}
|
||
s_gsdump_path = path;
|
||
// QueueSnapshot only honours a caller-supplied path when it ends in
|
||
// ".png" (it strips the extension and appends the dump's own).
|
||
if (!StringUtil::EndsWithNoCase(s_gsdump_path, ".png"))
|
||
s_gsdump_path += ".png";
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--gsdump-at"))
|
||
{
|
||
const std::optional<u32> n = StringUtil::FromChars<u32>(std::string_view(argv[++i]), 10);
|
||
if (!n.has_value())
|
||
{
|
||
Console.Error("--gsdump-at expects a frame number.");
|
||
return false;
|
||
}
|
||
s_gsdump_at = n.value();
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG("--perf-jitdump"))
|
||
{
|
||
// Emit a Linux perf jitdump so `perf inject --jit` can resolve EE_/VU1_/...
|
||
// JIT block symbols. Profiling only; honors explicit --renderer null (the
|
||
// liverun null->VK force below is skipped when this is set). Requires a
|
||
// USE_PERF_JITDUMP build (no-op otherwise).
|
||
s_perf_jitdump = true;
|
||
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("--statereport"))
|
||
{
|
||
// --statereport: load the savestate and print a field-level decode of
|
||
// every serialized timebase and transfer-engine state. No run.
|
||
s_mode = RunMode::StateReport;
|
||
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("--twindump"))
|
||
{
|
||
// Standalone = golden-dump mode; alongside --twincompare it only
|
||
// names the local trace/ram-dump prefix.
|
||
if (s_mode != RunMode::TwinCompare)
|
||
s_mode = RunMode::TwinDump;
|
||
s_twin_prefix = StringUtil::StripWhitespace(argv[++i]);
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--twincompare"))
|
||
{
|
||
s_mode = RunMode::TwinCompare;
|
||
s_twin_other = StringUtil::StripWhitespace(argv[++i]);
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--twin-label"))
|
||
{
|
||
s_twin_label = StringUtil::StripWhitespace(argv[++i]);
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--twin-ram-at"))
|
||
{
|
||
const auto v = StringUtil::FromChars<uint32_t>(argv[++i]);
|
||
if (!v.has_value())
|
||
{
|
||
Console.Error("Invalid --twin-ram-at value.");
|
||
return false;
|
||
}
|
||
s_twin_ram_at = static_cast<int>(v.value());
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--dump-config"))
|
||
{
|
||
s_mode = RunMode::DumpConfig;
|
||
s_dumpcfg_path = StringUtil::StripWhitespace(argv[++i]);
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--mkstate"))
|
||
{
|
||
s_mode = RunMode::MkState;
|
||
s_mkstate_path = StringUtil::StripWhitespace(argv[++i]);
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--set"))
|
||
{
|
||
const std::string kv(StringUtil::StripWhitespace(argv[++i]));
|
||
const size_t eq = kv.find('=');
|
||
const size_t slash = (eq == std::string::npos) ? std::string::npos : kv.rfind('/', eq);
|
||
if (eq == std::string::npos || slash == std::string::npos || slash == 0 || slash + 1 >= eq)
|
||
{
|
||
Console.Error("--set expects Section/Key=Value (e.g. EmuCore/CPU/Recompiler/fpuFullMode=true).");
|
||
return false;
|
||
}
|
||
s_set_overrides.push_back({kv.substr(0, slash), kv.substr(slash + 1, eq - slash - 1), kv.substr(eq + 1)});
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--rec-fallback"))
|
||
{
|
||
const std::string_view list = StringUtil::StripWhitespace(argv[++i]);
|
||
#if defined(ARCH_ARM64)
|
||
std::string err;
|
||
u32 mask = 0;
|
||
u32 reg_masks[EERecFallback::kCop2MoveOpCount] = {};
|
||
u64 vu_mask[EERecFallback::kCop2VuIdCount / 64] = {};
|
||
if (!EERecFallback::ParseGroups(list, &mask, reg_masks, vu_mask, &err))
|
||
{
|
||
Console.Error(err.c_str());
|
||
return false;
|
||
}
|
||
s_rec_fallback_groups = mask;
|
||
std::memcpy(s_rec_fallback_reg_masks, reg_masks, sizeof(reg_masks));
|
||
std::memcpy(s_rec_fallback_vu_mask, vu_mask, sizeof(vu_mask));
|
||
#else
|
||
Console.Error("--rec-fallback is implemented for the arm64 EE recompiler only.");
|
||
return false;
|
||
#endif
|
||
continue;
|
||
}
|
||
else if (CHECK_ARG_PARAM("--renderer"))
|
||
{
|
||
const std::string_view r = StringUtil::StripWhitespace(argv[++i]);
|
||
s_renderer_explicit = true;
|
||
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<uint32_t>(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, --repro, --twindump, --twincompare, or --dump-config).");
|
||
return false;
|
||
}
|
||
|
||
if (s_savestate_path.empty())
|
||
{
|
||
// --dump-config only needs the disc mounted: GameDB keys off the serial,
|
||
// which is known right after Initialize. --mkstate can cold-boot the
|
||
// disc to CREATE the first state. Every other mode replays state.
|
||
if (s_mode != RunMode::DumpConfig && s_mode != RunMode::MkState)
|
||
{
|
||
Console.Error("No savestate provided (use --savestate <file>).");
|
||
return false;
|
||
}
|
||
}
|
||
else if (!FileSystem::FileExists(s_savestate_path.c_str()))
|
||
{
|
||
Console.ErrorFmt("Savestate '{}' does not exist.", s_savestate_path);
|
||
return false;
|
||
}
|
||
|
||
if (!s_gsdump_path.empty())
|
||
{
|
||
if (s_mode != RunMode::LiveRun)
|
||
{
|
||
Console.Error("--gsdump needs --liverun: the diff modes suppress the real GS, so there is no GIF stream to record.");
|
||
return false;
|
||
}
|
||
if (s_renderer_explicit && s_renderer == GSRendererType::Null)
|
||
{
|
||
Console.Error("--gsdump cannot use --renderer null: Null drops GIF/PATH3 instead of consuming it.");
|
||
return false;
|
||
}
|
||
// Recording is armed after frame s_gsdump_at and needs s_gsdump_frames more
|
||
// vsyncs to close the file; a short --frames would truncate it silently.
|
||
if (s_frames < s_gsdump_at + s_gsdump_frames + 1)
|
||
{
|
||
s_frames = s_gsdump_at + s_gsdump_frames + 1;
|
||
Console.WarningFmt("--gsdump: raising --frames to {} so the dump can finish.", s_frames);
|
||
}
|
||
}
|
||
|
||
if (s_iso_path.empty())
|
||
{
|
||
Console.Error("No ISO provided (use --iso <file>); 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;
|
||
// Liverun normally needs a real GS (Null drops GIF/PATH3), so a DEFAULTED Null is
|
||
// forced to VK. An EXPLICIT --renderer null (or --perf-jitdump profiling) is
|
||
// honored: codegen_ab.sh depends on it for deterministic A/Bs on boxes whose
|
||
// Vulkan is unusable from a scripted session (mq65 turnip reports 1.0 over ssh).
|
||
if (live && rend == GSRendererType::Null && !s_perf_jitdump && !s_renderer_explicit)
|
||
rend = GSRendererType::VK;
|
||
s_settings_interface.SetIntValue("EmuCore/GS", "Renderer", static_cast<int>(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);
|
||
|
||
// EERUNNER_EECYCLESKIP / EERUNNER_EECYCLERATE: apply the EE cycle
|
||
// speedhacks. The Android Low-End preset ships EECycleSkip=1 and handheld
|
||
// users add underclock on top; timing-poison triage needs the handheld's
|
||
// clock shape reproducible on the desk.
|
||
if (const char* e = std::getenv("EERUNNER_EECYCLESKIP"))
|
||
s_settings_interface.SetIntValue("EmuCore/Speedhacks", "EECycleSkip", std::atoi(e));
|
||
if (const char* e = std::getenv("EERUNNER_EECYCLERATE"))
|
||
s_settings_interface.SetIntValue("EmuCore/Speedhacks", "EECycleRate", std::atoi(e));
|
||
|
||
// 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.
|
||
// EERUNNER_VU0FP=1 adds VU0 macro-visible state (VF00-31 + ACC + the
|
||
// STATUS/MAC/CLIP flag VIs) to the divtrace fingerprint + snapshot diff, so
|
||
// --stepdiff's zoom breaks at the EE block whose COP2 macro emission first
|
||
// produced divergent VU0 state (default fingerprint only sees GPR/FPR/CP0,
|
||
// which surfaces macro bugs downstream — e.g. the Jak 3 fMax vertex flood
|
||
// showed up at an unrelated memcpy block). Opt-in because mVU-vs-interp has
|
||
// by-design NaN/clamp corners the benign filters were not tuned for.
|
||
if (const char* e = std::getenv("EERUNNER_VU0FP"))
|
||
ee_divtrace::g_vu0_include = (e[0] != '0');
|
||
|
||
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);
|
||
|
||
// EERUNNER_HWDL=<0..4> forces EmuCore/GS HWDownloadMode (0=Enabled 1=EnabledForceFull
|
||
// 2=NoReadbacks 3=Unsynchronized 4=Disabled). Diagnostic for GPU-readback pipeline
|
||
// serialization (OutRun 2006 SD865: fps ≈ 1/(GS_cpu + GPU) because GSDownloadTexture::
|
||
// Flush() drains the GPU whenever the game reads back RT data mid-frame).
|
||
// Unsynchronized keeps all CPU-side copy work but skips the fence wait — isolates the
|
||
// stall; NoReadbacks/Disabled also skip the copies. 1-4 are NOT render-accurate; A/B only.
|
||
if (const char* e = std::getenv("EERUNNER_HWDL"))
|
||
s_settings_interface.SetIntValue("EmuCore/GS", "HWDownloadMode", atoi(e));
|
||
|
||
// EERUNNER_SPINCPU=1 sets EmuCore/GS HWSpinCPUForReadbacks: fence waits at GPU
|
||
// readbacks spin instead of sleeping, cutting the scheduler wake latency that
|
||
// dominates each synchronous readback round-trip once the pipeline overlap fix
|
||
// (mid-frame command buffer kick) has the GPU already caught up.
|
||
if (const char* e = std::getenv("EERUNNER_SPINCPU"))
|
||
s_settings_interface.SetBoolValue("EmuCore/GS", "HWSpinCPUForReadbacks", e[0] != '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");
|
||
|
||
// Profiling: drive the perf jitdump enable through the normal config path so
|
||
// ApplySettings/LoadSettings (which re-applies Perf::SetJitDumpEnabled from this
|
||
// bool) keeps it on for the whole run instead of resetting it to the default.
|
||
s_settings_interface.SetBoolValue("EmuCore/Profiler", "EnablePerfDump", s_perf_jitdump);
|
||
|
||
// 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(), "");
|
||
}
|
||
|
||
// --set Section/Key=Value overrides, applied LAST so they win over the
|
||
// harness pinning above. Base-layer only: a GameDB per-game override of the
|
||
// same key still wins (that's the production layering — useful for testing
|
||
// a pending GameDB fix). The twin passes own the CPU-provider toggles
|
||
// (EnableEE/VU0/VU1/IOP); a --set of those is clobbered per pass.
|
||
for (const SetOverride& so : s_set_overrides)
|
||
{
|
||
s_settings_interface.SetStringValue(so.section.c_str(), so.key.c_str(), so.value.c_str());
|
||
Console.WriteLn(fmt::format("SettingsOverride: --set [{}] {} = {}", so.section, so.key, so.value));
|
||
}
|
||
|
||
#if defined(ARCH_ARM64)
|
||
// --rec-fallback: route whole EE opcode groups through the interpreter. Not a
|
||
// setting (it lives in the recompiler, not EmuConfig), so it is applied here,
|
||
// before any block is compiled, and stays put for the whole run.
|
||
EERecFallback::g_groups = s_rec_fallback_groups;
|
||
std::memcpy(EERecFallback::g_cop2RegMask, s_rec_fallback_reg_masks, sizeof(s_rec_fallback_reg_masks));
|
||
std::memcpy(EERecFallback::g_cop2VuMask, s_rec_fallback_vu_mask, sizeof(s_rec_fallback_vu_mask));
|
||
if (s_rec_fallback_groups != 0)
|
||
{
|
||
Console.WriteLn(fmt::format("EE REC FALLBACK: forcing to interpreter -> {}",
|
||
EERecFallback::DescribeGroups(s_rec_fallback_groups)));
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// 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));
|
||
std::memcpy(fs.vu0_vf, VU0.VF, 32 * 16);
|
||
std::memcpy(fs.vu0_vf + 32 * 16, &VU0.ACC, 16);
|
||
fs.vu0_vi_status = VU0.VI[REG_STATUS_FLAG].UL;
|
||
fs.vu0_vi_mac = VU0.VI[REG_MAC_FLAG].UL;
|
||
fs.vu0_vi_clip = VU0.VI[REG_CLIP_FLAG].UL;
|
||
fs._pad0 = 0;
|
||
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<std::string> 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<SelfCheckFrame> RunAndSnapshot()
|
||
{
|
||
std::vector<SelfCheckFrame> 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<u8> 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<u8> 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<u8>& a, const std::vector<u8>& b, bool verbose = true, size_t max_show = 8)
|
||
{
|
||
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 < max_show)
|
||
{
|
||
++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<SelfCheckFrame>& a,
|
||
const std::vector<SelfCheckFrame>& 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<std::vector<SelfCheckFrame>> 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<ee_divtrace::Sample> 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<ee_divtrace::Sample>& interp,
|
||
const std::vector<ee_divtrace::Sample>& 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<uint32_t>(k), static_cast<uint32_t>(ii), jit[k].pc, prev_pc};
|
||
if (interp[scan].fp != jit[k].fp)
|
||
return {true, false, static_cast<uint32_t>(k), static_cast<uint32_t>(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<ee_divtrace::Sample>& interp,
|
||
const std::vector<ee_divtrace::Sample>& 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<ee_divtrace::Sample>& interp,
|
||
const std::vector<ee_divtrace::Sample>& jit, uint32_t div_k, uint32_t div_ii)
|
||
{
|
||
size_t ii = static_cast<size_t>(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<uint32_t>(k) + 1;
|
||
r.ii = static_cast<uint32_t>(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<std::string> DiffFullSnaps(const ee_divtrace::FullSnap& jit,
|
||
const ee_divtrace::FullSnap& interp)
|
||
{
|
||
std::vector<std::string> 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);
|
||
}
|
||
// VU0 macro state — reported only when it participates in alignment
|
||
// (EERUNNER_VU0FP), so the default report set stays what the benign-class
|
||
// filters were tuned on.
|
||
if (ee_divtrace::g_vu0_include)
|
||
{
|
||
static const char lanes[4] = {'x', 'y', 'z', 'w'};
|
||
for (int i = 0; i < 33; ++i)
|
||
{
|
||
u32 a[4], b[4];
|
||
std::memcpy(a, jit.vu0_vf + i * 16, 16);
|
||
std::memcpy(b, interp.vu0_vf + i * 16, 16);
|
||
const std::string name = (i == 32) ? std::string("vu0.ACC") : fmt::format("vu0.vf{:02}", i);
|
||
for (int l = 0; l < 4; ++l)
|
||
d32(fmt::format("{}.{}", name, lanes[l]), a[l], b[l]);
|
||
}
|
||
d32("vu0.vi.STATUS", jit.vu0_vi_status, interp.vu0_vi_status);
|
||
d32("vu0.vi.MAC", jit.vu0_vi_mac, interp.vu0_vi_mac);
|
||
d32("vu0.vi.CLIP", jit.vu0_vi_clip, interp.vu0_vi_clip);
|
||
}
|
||
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<std::string> ClassifyCycleDerivedLoads(u32 block_pc,
|
||
const std::vector<int>& divergent_gprs, std::vector<int>* classified_out = nullptr,
|
||
u32 end_pc = 0, const ee_divtrace::FullSnap* entry_snap = nullptr, u32 maxInsns = 48)
|
||
{
|
||
std::vector<std::string> 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
|
||
if (entry_snap)
|
||
{
|
||
// Seed the const-tracker with the interpreter's ACTUAL register file at
|
||
// the offending block's entry. Without this, a load whose base address
|
||
// was materialized in an EARLIER block (lui 0x1000 there, ori 0x800
|
||
// here) can't be proven to hit a timer COUNT register and under-taints
|
||
// — the common software-virtual-clock accumulator shape spans blocks.
|
||
// Every unmodeled GPR-writing op below must clear known[] so a seeded
|
||
// value can never go stale into an over-taint.
|
||
for (int i = 1; i < 32; ++i)
|
||
{
|
||
regval[i] = static_cast<u32>(entry_snap->cpu.GPR.r[i].UD[0]);
|
||
known[i] = true;
|
||
}
|
||
}
|
||
|
||
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;
|
||
// The divergence was OBSERVED entering end_pc — the offending block's
|
||
// body is [block_pc, end_pc). Walking past the observation point (e.g.
|
||
// through a following block's jr + delay slot when the offending block
|
||
// ended at an ei) lets an untainted overwrite in code that never ran
|
||
// erase taint that was live at the observation point.
|
||
if (end_pc && addr >= end_pc)
|
||
break;
|
||
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<s16>(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);
|
||
};
|
||
auto isCyclePhaseMmio = [&](u32 a) {
|
||
// Any EE hardware-register load whose value reflects transfer/counter
|
||
// PROGRESS is cycle-phase-derived the same way a timer COUNT is: the
|
||
// JIT and interp accumulate cycles at different granularity, so DMAC
|
||
// CHCR/MADR/QWC/TADR tag fields, FIFO counts, GS CSR etc. legitimately
|
||
// read back a different phase. The 0x1000_xxxx bank plus the GS
|
||
// privileged bank. The ResyncAfter reconvergence gate downstream is
|
||
// what keeps this broad class from masking a real codegen bug.
|
||
return isTimerCount(a) || (a >= 0x10000000 && a < 0x10010000) ||
|
||
(a >= 0x12000000 && a < 0x12002000);
|
||
};
|
||
|
||
// 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] && isCyclePhaseMmio(regval[rs] + static_cast<u32>(simm)))
|
||
{
|
||
const u32 ea = regval[rs] + static_cast<u32>(simm);
|
||
if (isTimerCount(ea))
|
||
{
|
||
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, true, fmt::format("cycle-phase MMIO ({:#010x}) read at pc={:#010x}", 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<u32>(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
|
||
// AND const-knowledge (a seeded entry value must not survive an
|
||
// unmodeled write). jr/jalr/sync have rd=0, harmless.
|
||
setTaint(rd, false, {});
|
||
known[rd] = false;
|
||
}
|
||
}
|
||
else if (op == 0x20 || op == 0x21 || op == 0x22 || op == 0x24 || op == 0x25 ||
|
||
op == 0x26 || op == 0x1a || op == 0x1b)
|
||
{
|
||
// Sub-word / unaligned loads (lb/lh/lwl/lbu/lhu/lwr/ldl/ldr): rt gets
|
||
// a fresh unmodeled value — clear const-knowledge and taint.
|
||
setTaint(rt, false, {});
|
||
known[rt] = false;
|
||
}
|
||
else if (op == 0x03 /*JAL*/)
|
||
{
|
||
setTaint(31, false, {});
|
||
known[31] = false;
|
||
}
|
||
else if (op == 0x1c /*MMI*/)
|
||
{
|
||
setTaint(rd, false, {});
|
||
known[rd] = false;
|
||
}
|
||
else if (op == 0x10 || op == 0x11 || op == 0x12)
|
||
{
|
||
// COPx moves (mfc/cfc land here when not the modeled MFC0 Count):
|
||
// conservatively treat rt as freshly written.
|
||
setTaint(rt, false, {});
|
||
known[rt] = false;
|
||
}
|
||
// (Stores, branches write no GPR — taint and const-knowledge 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<s16>(code & 0xffff);
|
||
const u32 target = addr + 4 + (static_cast<u32>(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<ee_divtrace::Sample>& interp,
|
||
const std::vector<ee_divtrace::Sample>& 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<uint32_t>(k) + 1;
|
||
r.ii = static_cast<uint32_t>(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;
|
||
// EERUNNER_CENSUS=1: instead of stopping at the first non-benign data
|
||
// divergence, report it compactly, resync unconditionally, and keep
|
||
// walking. Transient divergences (DMA-phase staging buffers, double-
|
||
// buffered comm regions that reconverge within the frame) get logged and
|
||
// skipped; the walk stops at the first PERSISTENT divergence — the one
|
||
// whose effects never wash out of the register/VU0 fingerprint. Built for
|
||
// the Jak 3 hunt, where the first-stop site was a shared-benign DMA-phase
|
||
// memcpy and the real (arm64-only) VU0-macro corruption came later.
|
||
const char* census_env = std::getenv("EERUNNER_CENSUS");
|
||
const bool census = census_env && census_env[0] != '0';
|
||
int census_sites = 0;
|
||
const int kCensusCap = 64;
|
||
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
|
||
}
|
||
// Census mode: a control-flow split that RECONVERGES (JIT re-joins
|
||
// interp's pc+fp stream) is a poll-phase artifact — e.g. a DMA-busy
|
||
// flag poll the JIT entered because it reached the check at a
|
||
// different cycle phase (Jak 3: the CHCR.STR-style wait at
|
||
// 0x10df40). Log it and keep walking; a genuine wrong-target branch
|
||
// perturbs state and will not reconverge.
|
||
if (census && census_sites < kCensusCap)
|
||
{
|
||
const ResyncResult rs = ResyncAfter(interp_fine, jit_fine, ar.jit_idx, ar.interp_idx);
|
||
if (rs.reconverged)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS site {}: CONTROL-FLOW split at pc={:#010x} (from block {:#010x}, JIT entry #{}) — "
|
||
"TRANSIENT, reconverged after a {}-entry blind window (poll-phase class); continuing.",
|
||
census_sites, ar.pc, ar.prev_pc, ar.jit_idx, rs.blind));
|
||
++census_sites;
|
||
k = rs.k;
|
||
ii = rs.ii;
|
||
prev_pc = rs.prev_pc;
|
||
continue;
|
||
}
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS site {}: CONTROL-FLOW split at pc={:#010x} — PERSISTENT (never reconverged, blind={}, "
|
||
"ran_off={}). Full report follows.",
|
||
census_sites, ar.pc, rs.blind, rs.ran_off));
|
||
++census_sites;
|
||
// Snapshot the JIT side at the split entry so the guest register
|
||
// operands of the diverging test are visible (e.g. the busy-flag
|
||
// address a poll loop is spinning on), plus both sides' view of
|
||
// the polled word when it resolves to RAM.
|
||
if (reload(true))
|
||
{
|
||
const auto csnap = RunFineSnapAtFromHere(ar.jit_idx);
|
||
static const char* const gprn[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"};
|
||
std::string regs;
|
||
for (int gi : {2, 4, 5, 6, 16, 17, 18})
|
||
regs += fmt::format("{}={:#x} ", gprn[gi], csnap.cpu.GPR.r[gi].UD[0]);
|
||
Console.WriteLn(fmt::format("CENSUS split-entry JIT regs: {}", regs));
|
||
const u32 s0addr = static_cast<u32>(csnap.cpu.GPR.r[16].UD[0]);
|
||
if ((s0addr & 0xF0000000u) == 0 && (s0addr & 0x0FFFFFFFu) < Ps2MemSize::MainRam)
|
||
{
|
||
const u32 flagjit = *reinterpret_cast<const u32*>(
|
||
reinterpret_cast<const u8*>(eeMem->Main) + (s0addr & 0x1ffffffu));
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split: [s0={:#010x}] (RAM) = {:#010x} on the JIT side at entry.", s0addr, flagjit));
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split: s0={:#010x} is not a plain RAM address (HW/scratch/kseg) — poll target is MMIO.",
|
||
s0addr));
|
||
}
|
||
// The re-run left the VM at END of the JIT frame — dump the SPR
|
||
// DMA channel state (both directions) so a wedged channel's
|
||
// CHCR/MADR/QWC/TADR are visible on the JIT side...
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split JIT frame-end SPR1(toSPR): chcr={:#010x} madr={:#010x} qwc={:#x} tadr={:#010x} sadr={:#010x}",
|
||
psHu32(0xD400), psHu32(0xD410), psHu32(0xD420), psHu32(0xD430), psHu32(0xD480)));
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split JIT frame-end SPR0(fromSPR): chcr={:#010x} madr={:#010x} qwc={:#x} sadr={:#010x}",
|
||
psHu32(0xD000), psHu32(0xD010), psHu32(0xD020), psHu32(0xD080)));
|
||
// ...and the same after a fresh interp frame for comparison.
|
||
if (reload(false))
|
||
{
|
||
AdvanceFrames(1);
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split INTERP frame-end SPR1(toSPR): chcr={:#010x} madr={:#010x} qwc={:#x} tadr={:#010x} sadr={:#010x}",
|
||
psHu32(0xD400), psHu32(0xD410), psHu32(0xD420), psHu32(0xD430), psHu32(0xD480)));
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS split INTERP frame-end SPR0(fromSPR): chcr={:#010x} madr={:#010x} qwc={:#x} sadr={:#010x}",
|
||
psHu32(0xD000), psHu32(0xD010), psHu32(0xD020), psHu32(0xD080)));
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
// The self-loop can be the block being ENTERED (a re-entry sample), OR
|
||
// the offending block just executed (ar.prev_pc): the interpreter
|
||
// samples the loop's terminating-branch delay slot at branch+8, which
|
||
// aliases the loop's fall-through EXIT pc every iteration — so the
|
||
// divergence is reported "entering <exit>" with the loop sitting in
|
||
// prev_pc. Check the entered block first, then the offending block.
|
||
u32 loop_target = BlockBackwardBranchTarget(ar.pc, &loop_branch_addr);
|
||
if (loop_target == 0 && ar.prev_pc)
|
||
loop_target = BlockBackwardBranchTarget(ar.prev_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<int> 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);
|
||
|
||
// Seed the timer-taint classifier with the interpreter's register file at
|
||
// the offending block's ENTRY (the last interp sample at prev_pc before
|
||
// the divergence index) so load addresses materialized in earlier blocks
|
||
// resolve. Costs one extra frame re-run per data divergence.
|
||
ee_divtrace::FullSnap esnap{};
|
||
bool have_esnap = false;
|
||
if (ar.prev_pc)
|
||
{
|
||
for (u32 pi = ar.interp_idx; pi-- > 0;)
|
||
{
|
||
if (interp_fine[pi].pc == ar.prev_pc)
|
||
{
|
||
if (!reload(false))
|
||
return true;
|
||
esnap = RunFineSnapAtFromHere(pi);
|
||
have_esnap = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
std::vector<int> classified;
|
||
const auto timer_notes = ClassifyCycleDerivedLoads(ar.prev_pc, divergent_gprs, &classified, ar.pc,
|
||
have_esnap ? &esnap : nullptr);
|
||
|
||
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;
|
||
}
|
||
|
||
// Census mode: log the site, resync unconditionally, continue while the
|
||
// divergence proves transient. Falls through to the full report (stop)
|
||
// on the first site that never reconverges, or at the cap.
|
||
if (census && census_sites < kCensusCap)
|
||
{
|
||
const auto cdiffs = DiffFullSnaps(jsnap, isnap);
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS site {}: entering pc={:#010x} (offending block {:#010x}, JIT entry #{}), {} divergent field(s):",
|
||
census_sites, ar.pc, ar.prev_pc, ar.jit_idx, cdiffs.size()));
|
||
for (size_t di = 0; di < cdiffs.size() && di < 10; ++di)
|
||
Console.WriteLn(fmt::format(" {}", cdiffs[di]));
|
||
if (cdiffs.size() > 10)
|
||
Console.WriteLn(fmt::format(" ... (+{} more)", cdiffs.size() - 10));
|
||
const ResyncResult rs = ResyncAfter(interp_fine, jit_fine, ar.jit_idx, ar.interp_idx);
|
||
if (rs.reconverged)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS site {}: TRANSIENT — reconverged after a {}-entry blind window; continuing.",
|
||
census_sites, rs.blind));
|
||
++census_sites;
|
||
k = rs.k;
|
||
ii = rs.ii;
|
||
prev_pc = rs.prev_pc;
|
||
continue;
|
||
}
|
||
Console.WriteLn(fmt::format(
|
||
"CENSUS site {}: PERSISTENT — never reconverged (blind={}, ran_off={}). Full report follows.",
|
||
census_sites, rs.blind, rs.ran_off));
|
||
++census_sites;
|
||
// fall through to the full report + stop
|
||
}
|
||
|
||
// 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);
|
||
|
||
// Offending-block ENTRY probe: dump BOTH streams' GPR file at prev_pc
|
||
// (the offending block's head). Tells apart "entered already-diverged =
|
||
// upstream culprit" from "entered identical, exits diverged = this block
|
||
// is the bug", and hands a single-block fixture the real seed values.
|
||
if (ar.prev_pc)
|
||
{
|
||
auto entry_snap = [&](const std::vector<ee_divtrace::Sample>& stream, uint32_t upto,
|
||
bool jit_side, ee_divtrace::FullSnap& out) -> bool {
|
||
for (uint32_t pi = upto; pi-- > 0;)
|
||
{
|
||
if (stream[pi].pc == ar.prev_pc)
|
||
{
|
||
if (!reload(jit_side))
|
||
return false;
|
||
out = RunFineSnapAtFromHere(pi);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
ee_divtrace::FullSnap ientry{}, jentry{};
|
||
const bool hi = entry_snap(interp_fine, ar.interp_idx, false, ientry);
|
||
const bool hj = entry_snap(jit_fine, ar.jit_idx, true, jentry);
|
||
if (hi && hj)
|
||
{
|
||
Console.WriteLn(fmt::format("STEPDIFF zoom: offending-block ENTRY GPRs @ {:#010x} (interp | jit):", ar.prev_pc));
|
||
for (int r = 0; r < 32; ++r)
|
||
{
|
||
const u64 iv = ientry.cpu.GPR.r[r].UD[0];
|
||
const u64 jv = jentry.cpu.GPR.r[r].UD[0];
|
||
if (iv != jv || (r >= 4 && r <= 7))
|
||
Console.WriteLn(fmt::format(" r{:<2} {:#018x} | {:#018x}{}", r, iv, jv,
|
||
iv != jv ? " <-- DIFFERS" : ""));
|
||
}
|
||
Console.WriteLn("STEPDIFF zoom: offending-block ENTRY FPRs (interp | jit; f0-f3,f12 + diffs):");
|
||
for (int f = 0; f < 32; ++f)
|
||
{
|
||
const u32 iv = ientry.fpu.fpr[f].UL;
|
||
const u32 jv = jentry.fpu.fpr[f].UL;
|
||
if (iv != jv || f <= 3 || f == 12)
|
||
Console.WriteLn(fmt::format(" f{:<2} {:#010x} | {:#010x}{}", f, iv, jv,
|
||
iv != jv ? " <-- DIFFERS" : ""));
|
||
}
|
||
}
|
||
}
|
||
|
||
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<uint64_t>* cycles = nullptr) -> std::vector<uint64_t> {
|
||
std::vector<uint64_t> 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<uint64_t>(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<uint64_t> 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<u8> {
|
||
std::vector<u8> 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<u8> 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<const char*>(m.data()), static_cast<std::streamsize>(m.size()));
|
||
Console.WriteLn(fmt::format("CONTMEM: wrote {} ({} bytes, frame {}).", path, m.size(), lastf));
|
||
};
|
||
dump(false, "interp");
|
||
dump(true, "jit");
|
||
}
|
||
return EXIT_SUCCESS;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// --twindump / --twincompare : cross-BUILD divergence finder ("twindiff").
|
||
//
|
||
// Motivation: a JIT transplant regressed games the pre-transplant tree (its
|
||
// own independent JIT stack) runs correctly. The in-tree oracles compare a
|
||
// JIT against ITS OWN interpreter, so they are structurally blind to two
|
||
// classes: (a) shared-C++ semantic drift between the trees (both engines in
|
||
// one tree agree with each other AND with the bug), and (b) behavior the
|
||
// WORKING tree's JIT has that the interpreter also lacks (True Crime
|
||
// guard-bit adds: interp never masked, so jit-vs-interp reads "clean" while
|
||
// the game needs the masking).
|
||
//
|
||
// Design: the same byte-identical runner is built in BOTH trees. From one
|
||
// shared savestate each binary makes three free-running passes over --frames:
|
||
// i1, i2 full interpreter (EE+VU0+VU1+IOP interp); i2 is the determinism
|
||
// control. The interp cells are pure shared-C++.
|
||
// j the full production JIT stack of that tree.
|
||
// recording per frame: xxh3 of EE RAM+scratch (HashMemory), the architectural
|
||
// register fingerprint (FingerprintCpu), and cpuRegs.cycle. --twindump writes
|
||
// <prefix>.twin; --twincompare re-runs the passes locally and diffs:
|
||
//
|
||
// pair XI other.i1 vs local.i1 -> shared-C++ semantic drift (sharp: both
|
||
// sides claim identical semantics AND
|
||
// identical cycle accounting)
|
||
// pair TJ other.j vs other.i1 -> the other tree's JIT-vs-interp envelope
|
||
// pair OJ local.j vs local.i1 -> local JIT bug (hand to --stepdiff)
|
||
// pair EE other.j vs local.j -> end-to-end working-vs-broken headline
|
||
//
|
||
// TJ/OJ/EE carry benign divergence by construction: interp and each JIT use
|
||
// different cycle accounting, so timer-derived values land in RAM and drift
|
||
// the memhash. Adjudicate those with the RAM page-diff character (scattered
|
||
// small deltas = timing noise; structured buffers = value bug) and config A/B
|
||
// (--set). The guard-bit signature: XI clean + OJ clean-ish + TJ divergent +
|
||
// EE divergent. Memhash is primary; regfp is advisory (frame-pause register
|
||
// sampling jitters ~10 cycles). Free-running passes accumulate divergence, so
|
||
// keep --frames modest and the savestate near the symptom — the i1-vs-i2
|
||
// control reports where each tree's own determinism floor breaks.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
namespace
|
||
{
|
||
struct TwinRec
|
||
{
|
||
u64 mem;
|
||
u64 reg;
|
||
u64 cyc;
|
||
};
|
||
|
||
struct TwinTrace
|
||
{
|
||
std::string label;
|
||
std::string serial;
|
||
uint32_t frames = 0;
|
||
int vu0fp = 0;
|
||
int nofp = 0;
|
||
std::vector<TwinRec> i1, i2, j;
|
||
};
|
||
} // namespace
|
||
|
||
// Force the WHOLE CPU stack (EE, VU0, VU1, IOP) to interp or to this tree's
|
||
// JITs. The twin interp cells must be pure shared-C++: with only EnableEE
|
||
// toggled (what SetEeMode does for the in-tree modes), the two trees'
|
||
// DIFFERENT VU/IOP JITs would leak into the "interpreter" comparison and
|
||
// conflate divergence classes.
|
||
static void SetAllCpuMode(bool jit)
|
||
{
|
||
s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableEE", jit);
|
||
s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableVU0", jit);
|
||
s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableVU1", jit);
|
||
s_settings_interface.SetBoolValue("EmuCore/CPU/Recompiler", "EnableIOP", jit);
|
||
VMManager::ApplySettings();
|
||
}
|
||
|
||
// One free-running pass from the start savestate. Optionally dumps raw EE
|
||
// RAM+scratch at --twin-ram-at to <prefix>.f<N>.{interp,jit}.bin (skipped for
|
||
// the i2 control twin).
|
||
static bool TwinRunPass(bool jit, const char* cell, std::vector<TwinRec>& out)
|
||
{
|
||
Error error;
|
||
out.clear();
|
||
if (!VMManager::LoadState(s_savestate_path.c_str(), &error))
|
||
{
|
||
Console.ErrorFmt("twin: load failed: {}", error.GetDescription());
|
||
return false;
|
||
}
|
||
SetAllCpuMode(jit);
|
||
out.reserve(s_frames);
|
||
for (uint32_t f = 0; f < s_frames && VMManager::GetState() != VMState::Shutdown; ++f)
|
||
{
|
||
VMManager::FrameAdvance(1);
|
||
VMManager::Execute();
|
||
TwinRec r;
|
||
r.mem = ee_divtrace::HashMemory();
|
||
r.reg = ee_divtrace::FingerprintCpu();
|
||
r.cyc = cpuRegs.cycle;
|
||
out.push_back(r);
|
||
if (s_twin_ram_at >= 0 && f == static_cast<uint32_t>(s_twin_ram_at) &&
|
||
std::strcmp(cell, "i2") != 0 && !s_twin_prefix.empty())
|
||
{
|
||
const std::string path = fmt::format("{}.f{}.{}.bin", s_twin_prefix, f, jit ? "jit" : "interp");
|
||
std::ofstream bf(path, std::ios::binary | std::ios::trunc);
|
||
if (bf)
|
||
{
|
||
bf.write(reinterpret_cast<const char*>(eeMem->Main), Ps2MemSize::MainRam);
|
||
bf.write(reinterpret_cast<const char*>(eeMem->Scratch), Ps2MemSize::Scratch);
|
||
Console.WriteLn(fmt::format("twin: wrote {} ({} bytes).", path,
|
||
static_cast<size_t>(Ps2MemSize::MainRam) + Ps2MemSize::Scratch));
|
||
}
|
||
else
|
||
Console.ErrorFmt("twin: failed to open {}", path);
|
||
}
|
||
}
|
||
Console.WriteLn(fmt::format("twin: pass {} done ({} frames).", cell, out.size()));
|
||
return true;
|
||
}
|
||
|
||
static bool TwinRunAll(TwinTrace& t)
|
||
{
|
||
t.label = s_twin_label;
|
||
t.serial = VMManager::GetDiscSerial();
|
||
t.frames = s_frames;
|
||
t.vu0fp = ee_divtrace::g_vu0_include ? 1 : 0;
|
||
t.nofp = ee_divtrace::g_fp_exclude ? 1 : 0;
|
||
return TwinRunPass(false, "i1", t.i1) &&
|
||
TwinRunPass(false, "i2", t.i2) &&
|
||
TwinRunPass(true, "j", t.j);
|
||
}
|
||
|
||
static bool WriteTwinTrace(const TwinTrace& t, const std::string& path)
|
||
{
|
||
std::ofstream f(path, std::ios::trunc);
|
||
if (!f)
|
||
{
|
||
Console.ErrorFmt("twin: failed to open {}", path);
|
||
return false;
|
||
}
|
||
f << "# twindiff trace v1\n";
|
||
f << fmt::format("meta label={} serial={} frames={} vu0fp={} nofp={} savestate={}\n",
|
||
t.label, t.serial.empty() ? "?" : t.serial, t.frames, t.vu0fp, t.nofp,
|
||
Path::GetFileName(s_savestate_path));
|
||
const auto cell = [&f](const char* name, const std::vector<TwinRec>& v) {
|
||
for (size_t i = 0; i < v.size(); ++i)
|
||
f << fmt::format("rec {} {} {:016x} {:016x} {}\n", i, name, v[i].mem, v[i].reg, v[i].cyc);
|
||
};
|
||
cell("i1", t.i1);
|
||
cell("i2", t.i2);
|
||
cell("j", t.j);
|
||
Console.WriteLn(fmt::format("twin: wrote {} ({} frames x 3 cells).", path, t.frames));
|
||
return true;
|
||
}
|
||
|
||
static bool LoadTwinTrace(TwinTrace& t, const std::string& path)
|
||
{
|
||
std::ifstream f(path);
|
||
if (!f)
|
||
{
|
||
Console.ErrorFmt("twin: failed to open {}", path);
|
||
return false;
|
||
}
|
||
std::string line;
|
||
while (std::getline(f, line))
|
||
{
|
||
if (line.empty() || line[0] == '#')
|
||
continue;
|
||
std::istringstream ss(line);
|
||
std::string tok;
|
||
ss >> tok;
|
||
if (tok == "meta")
|
||
{
|
||
std::string kv;
|
||
while (ss >> kv)
|
||
{
|
||
const size_t eq = kv.find('=');
|
||
if (eq == std::string::npos)
|
||
continue;
|
||
const std::string k = kv.substr(0, eq), v = kv.substr(eq + 1);
|
||
if (k == "label")
|
||
t.label = v;
|
||
else if (k == "serial")
|
||
t.serial = v;
|
||
else if (k == "frames")
|
||
t.frames = static_cast<uint32_t>(std::strtoul(v.c_str(), nullptr, 10));
|
||
else if (k == "vu0fp")
|
||
t.vu0fp = (v == "1");
|
||
else if (k == "nofp")
|
||
t.nofp = (v == "1");
|
||
}
|
||
}
|
||
else if (tok == "rec")
|
||
{
|
||
size_t idx = 0;
|
||
std::string cell, memh, regh;
|
||
u64 cyc = 0;
|
||
ss >> idx >> cell >> memh >> regh >> cyc;
|
||
if (ss.fail() || (cell != "i1" && cell != "i2" && cell != "j"))
|
||
{
|
||
Console.ErrorFmt("twin: malformed rec line in {}: '{}'", path, line);
|
||
return false;
|
||
}
|
||
TwinRec r;
|
||
r.mem = std::strtoull(memh.c_str(), nullptr, 16);
|
||
r.reg = std::strtoull(regh.c_str(), nullptr, 16);
|
||
r.cyc = cyc;
|
||
auto& v = (cell == "i1") ? t.i1 : (cell == "i2") ? t.i2 : t.j;
|
||
if (idx != v.size())
|
||
{
|
||
Console.ErrorFmt("twin: out-of-order rec index in {} (cell {} idx {}).", path, cell, idx);
|
||
return false;
|
||
}
|
||
v.push_back(r);
|
||
}
|
||
}
|
||
Console.WriteLn(fmt::format("twin: loaded {} (label={} serial={} i1/i2/j = {}/{}/{} frames).",
|
||
path, t.label, t.serial, t.i1.size(), t.i2.size(), t.j.size()));
|
||
return !t.i1.empty() && !t.i2.empty() && !t.j.empty();
|
||
}
|
||
|
||
// First frame index where the two cells' memhash (or regfp) differ; -1 = none.
|
||
static int TwinFirstDiff(const std::vector<TwinRec>& a, const std::vector<TwinRec>& b, bool reg)
|
||
{
|
||
const size_t n = std::min(a.size(), b.size());
|
||
for (size_t f = 0; f < n; ++f)
|
||
{
|
||
if (reg ? (a[f].reg != b[f].reg) : (a[f].mem != b[f].mem))
|
||
return static_cast<int>(f);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
static s64 TwinMaxCycDrift(const std::vector<TwinRec>& a, const std::vector<TwinRec>& b, int* at)
|
||
{
|
||
const size_t n = std::min(a.size(), b.size());
|
||
s64 maxd = 0;
|
||
*at = -1;
|
||
for (size_t f = 0; f < n; ++f)
|
||
{
|
||
// cycle is a u32 counter widened at capture; diff as s64 of the u32s
|
||
// (wrap artifacts only matter across ~14s spans, far beyond a window).
|
||
const s64 d = static_cast<s64>(a[f].cyc) - static_cast<s64>(b[f].cyc);
|
||
if (std::llabs(d) > std::llabs(maxd))
|
||
{
|
||
maxd = d;
|
||
*at = static_cast<int>(f);
|
||
}
|
||
}
|
||
return maxd;
|
||
}
|
||
|
||
static int RunTwinDump()
|
||
{
|
||
if (s_twin_prefix.empty())
|
||
{
|
||
Console.Error("twin: --twindump needs a <prefix>.");
|
||
return EXIT_FAILURE;
|
||
}
|
||
TwinTrace t;
|
||
if (!TwinRunAll(t))
|
||
return EXIT_FAILURE;
|
||
if (!WriteTwinTrace(t, s_twin_prefix + ".twin"))
|
||
return EXIT_FAILURE;
|
||
|
||
// Local quick summary so a lone dump is already informative.
|
||
const int ctrl = TwinFirstDiff(t.i1, t.i2, false);
|
||
const int jvi = TwinFirstDiff(t.j, t.i1, false);
|
||
int at = -1;
|
||
const s64 drift = TwinMaxCycDrift(t.j, t.i1, &at);
|
||
Console.WriteLn(fmt::format(
|
||
"TWINDUMP SUMMARY [{}]: interp determinism floor breaks at frame {}; local jit-vs-interp mem first differs at frame {} (cycle-model noise expected); max |jit-interp cycle drift| = {:+d} @ frame {}.",
|
||
t.label, ctrl, jvi, drift, at));
|
||
return EXIT_SUCCESS;
|
||
}
|
||
|
||
static int RunTwinCompare()
|
||
{
|
||
// Default the local prefix BEFORE the passes (the ram-at dumps use it).
|
||
if (s_twin_prefix.empty())
|
||
s_twin_prefix = s_twin_other + ".local";
|
||
|
||
TwinTrace other;
|
||
if (!LoadTwinTrace(other, s_twin_other))
|
||
return EXIT_FAILURE;
|
||
|
||
TwinTrace local;
|
||
if (!TwinRunAll(local))
|
||
return EXIT_FAILURE;
|
||
WriteTwinTrace(local, s_twin_prefix + ".twin");
|
||
|
||
if (other.vu0fp != local.vu0fp || other.nofp != local.nofp)
|
||
{
|
||
Console.ErrorFmt(
|
||
"twin: fingerprint config mismatch (other vu0fp={} nofp={}, local vu0fp={} nofp={}) — set matching EERUNNER_VU0FP / EERUNNER_NOFP on both sides.",
|
||
other.vu0fp, other.nofp, local.vu0fp, local.nofp);
|
||
return EXIT_FAILURE;
|
||
}
|
||
if (!other.serial.empty() && other.serial != "?" && !local.serial.empty() && other.serial != local.serial)
|
||
Console.ErrorFmt("twin: WARNING serial mismatch (other={} local={}) — comparing different games?", other.serial, local.serial);
|
||
|
||
const size_t n = std::min({other.i1.size(), other.i2.size(), other.j.size(),
|
||
local.i1.size(), local.i2.size(), local.j.size()});
|
||
if (n == 0)
|
||
{
|
||
Console.Error("twin: no overlapping frames.");
|
||
return EXIT_FAILURE;
|
||
}
|
||
|
||
// Determinism floors (per tree, interp-vs-interp).
|
||
const int oc = TwinFirstDiff(other.i1, other.i2, false);
|
||
const int lc = TwinFirstDiff(local.i1, local.i2, false);
|
||
|
||
struct Pair
|
||
{
|
||
const char* tag;
|
||
std::string desc;
|
||
const std::vector<TwinRec>* a;
|
||
const std::vector<TwinRec>* b;
|
||
};
|
||
const Pair pairs[] = {
|
||
{"XI", fmt::format("cross-tree interp-vs-interp ({} vs {}) [SHARP]", other.label, local.label), &other.i1, &local.i1},
|
||
{"TJ", fmt::format("{} jit-vs-interp envelope [cycle-model noisy]", other.label), &other.j, &other.i1},
|
||
{"OJ", fmt::format("{} jit-vs-interp envelope [cycle-model noisy]", local.label), &local.j, &local.i1},
|
||
{"EE", fmt::format("end-to-end jit-vs-jit ({} vs {}) [cycle-model noisy]", other.label, local.label), &other.j, &local.j},
|
||
};
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"TWINDIFF: other='{}' local='{}' frames={}; determinism floors: other frame {}, local frame {} (-1 = never broke).",
|
||
other.label, local.label, n, oc, lc));
|
||
|
||
int first_mem[4], first_reg[4], drift_at[4];
|
||
s64 drift[4];
|
||
for (int p = 0; p < 4; ++p)
|
||
{
|
||
first_mem[p] = TwinFirstDiff(*pairs[p].a, *pairs[p].b, false);
|
||
first_reg[p] = TwinFirstDiff(*pairs[p].a, *pairs[p].b, true);
|
||
drift[p] = TwinMaxCycDrift(*pairs[p].a, *pairs[p].b, &drift_at[p]);
|
||
Console.WriteLn(fmt::format(" {} {}: first mem-div frame {}, first regfp-div frame {} (advisory), max |cycle drift| {:+d} @ frame {}.",
|
||
pairs[p].tag, pairs[p].desc, first_mem[p], first_reg[p], drift[p], drift_at[p]));
|
||
}
|
||
|
||
// XI is real only while BOTH trees' own determinism floors are still clean.
|
||
const int xi = first_mem[0];
|
||
const bool xi_real = xi >= 0 && (oc < 0 || xi < oc) && (lc < 0 || xi < lc);
|
||
const int ee = first_mem[3];
|
||
|
||
Console.WriteLn("TWINDIFF VERDICT:");
|
||
if (xi_real)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
" SHARED-C++ DRIFT: the two trees' full interpreters diverge at frame {} (identical cycle model on both sides — semantic merge delta, not JIT). Re-run both sides with --twin-ram-at {} and page-diff the .interp.bin pair; suspects = the shared pcsx2/ files changed between the trees.",
|
||
xi, xi));
|
||
if (drift[0] != 0 && drift_at[0] <= xi)
|
||
Console.WriteLn(" (interp cycle trajectories also drift cross-tree — event-scheduling change, e.g. Counters — inspect that before trusting value-level diffs.)");
|
||
}
|
||
else if (xi >= 0)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
" cross-tree interp divergence at frame {} is AT/BEYOND a determinism floor (other {}, local {}) — inconclusive; move the savestate closer or shorten --frames.",
|
||
xi, oc, lc));
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLn(fmt::format(" cross-tree interp timelines IDENTICAL for {} frames — shared C++ is clean here.", n));
|
||
}
|
||
if (ee >= 0)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
" end-to-end jit-vs-jit mem first differs at frame {}. The jit pairs carry benign cycle-model divergence (timer-derived values land in RAM): adjudicate with the RAM page-diff character at that frame (--twin-ram-at {} on both sides; scattered small deltas = timing noise, structured buffers = value bug) and with config A/B (--set).",
|
||
ee, ee));
|
||
if (first_mem[1] >= 0 && first_mem[2] < 0)
|
||
Console.WriteLn(fmt::format(
|
||
" SIGNATURE: {} JIT diverges from shared interp (frame {}) while {} JIT tracks interp — if the game is broken here, it likely DEPENDS on the other JIT's semantics (guard-bit class).",
|
||
other.label, first_mem[1], local.label));
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLn(" no end-to-end jit-vs-jit divergence in the window — CPU-state clean here; suspect GS-side or config (diff --dump-config outputs), or move the savestate closer to the symptom.");
|
||
}
|
||
|
||
return (xi_real || ee >= 0) ? 2 : EXIT_SUCCESS;
|
||
}
|
||
|
||
// --mkstate <out.p2s> : bootstrap a shared savestate for twindiff. Boots the
|
||
// disc (or continues from --savestate), runs --frames under the production
|
||
// JIT stack, writes a savestate, exits. Make twindiff states on the WORKING
|
||
// (gold) binary: a state taken from a broken run can already carry the
|
||
// corruption baked into RAM — True Crime's paletted textures are computed
|
||
// once at level load, so a mid-scene state from a broken build shows no
|
||
// guard-bit delta at all; the window has to CROSS the corrupting computation.
|
||
static int RunMkState()
|
||
{
|
||
Error error;
|
||
if (!s_savestate_path.empty())
|
||
{
|
||
if (!VMManager::LoadState(s_savestate_path.c_str(), &error))
|
||
{
|
||
Console.ErrorFmt("mkstate: load failed: {}", error.GetDescription());
|
||
return EXIT_FAILURE;
|
||
}
|
||
}
|
||
AdvanceFrames(s_frames);
|
||
bool ok = true;
|
||
VMManager::SaveState(s_mkstate_path.c_str(), /*zip_on_thread=*/false, /*backup_old_state=*/false,
|
||
[&](const std::string& e) { ok = false; Console.ErrorFmt("mkstate: save failed: {}", e); });
|
||
VMManager::WaitForSaveStateFlush();
|
||
Console.WriteLn(fmt::format("MKSTATE: {} after {} frames -> {}",
|
||
ok ? "wrote state" : "FAILED", s_frames, s_mkstate_path));
|
||
#if defined(ARCH_ARM64)
|
||
// Which VU macro ops this run actually compiled — the candidate list for a
|
||
// `--rec-fallback cop2vu:<mnemonic>` bisect.
|
||
Console.WriteLn(fmt::format("COP2VU CENSUS: {}", EERecFallback::DescribeCop2VuCensus()));
|
||
#endif
|
||
return ok ? EXIT_SUCCESS : EXIT_FAILURE;
|
||
}
|
||
|
||
// --dump-config <path> : boot the VM far enough for GameDB to apply, then
|
||
// serialize the EFFECTIVE EmuConfig to an INI. The twindiff driver diffs the
|
||
// two trees' dumps FIRST — config/GameDB drift (True Crime: guard-bit emulation
|
||
// default-off at the time + three divergent GameIndex copies) explains a broken
|
||
// game far more cheaply than any trace. Savestate optional: config keys off the disc
|
||
// serial, known right after Initialize.
|
||
static int RunDumpConfig()
|
||
{
|
||
INISettingsInterface ini(s_dumpcfg_path);
|
||
{
|
||
SettingsSaveWrapper wrap(ini);
|
||
EmuConfig.LoadSave(wrap);
|
||
}
|
||
if (!ini.Save())
|
||
{
|
||
Console.ErrorFmt("dump-config: failed to write {}", s_dumpcfg_path);
|
||
return EXIT_FAILURE;
|
||
}
|
||
Console.WriteLn(fmt::format("DUMPCONFIG: serial={} crc={:08x} -> {}",
|
||
VMManager::GetDiscSerial(), VMManager::GetDiscCRC(), s_dumpcfg_path));
|
||
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<uint64_t>* cyc = nullptr) -> std::vector<uint64_t> {
|
||
std::vector<uint64_t> 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<uint64_t>(cpuRegs.cycle));
|
||
}
|
||
return hashes;
|
||
};
|
||
|
||
auto capMem = [&](uint32_t mask, uint32_t frame) -> std::vector<u8> {
|
||
std::vector<u8> 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<int>(f); break; }
|
||
Console.WriteLn(fmt::format(
|
||
"SPEEDHACK-DIFF: baseline (all-off) stays run-to-run bit-identical through frame {} (of {}).",
|
||
floorFrame < 0 ? static_cast<int>(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<uint32_t> 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<std::vector<u8>> baseCaps, floorCaps;
|
||
std::vector<MemDiffCount> 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<MemDiffCount> traj; bool explosion; };
|
||
std::vector<Verdict> verdicts;
|
||
|
||
auto evalConfig = [&](uint32_t mask) {
|
||
std::vector<MemDiffCount> 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...");
|
||
// Byte-level store localization first: re-run this frame once per
|
||
// mode from the same checkpoint capturing EE RAM+scratch, and report
|
||
// the differing regions. Single-frame from an identical checkpoint
|
||
// with a clean interp control, so every differing byte is a real JIT
|
||
// store difference (no cross-frame accumulation, no async jitter).
|
||
{
|
||
auto runOneMem = [&](bool jit_mode, std::vector<u8>& mem) -> bool {
|
||
if (!loadCkpt())
|
||
return false;
|
||
SetEeMode(jit_mode);
|
||
AdvanceFrames(1);
|
||
mem.resize(Ps2MemSize::MainRam + Ps2MemSize::Scratch);
|
||
std::memcpy(mem.data(), eeMem->Main, Ps2MemSize::MainRam);
|
||
std::memcpy(mem.data() + Ps2MemSize::MainRam, eeMem->Scratch, Ps2MemSize::Scratch);
|
||
return true;
|
||
};
|
||
std::vector<u8> mi, mj;
|
||
if (runOneMem(false, mi) && runOneMem(true, mj))
|
||
{
|
||
Console.Error(" frame-local EE-RAM diff (A=interp, B=JIT):");
|
||
ReportMemDiff(mi, mj, /*verbose=*/true, /*max_show=*/64);
|
||
}
|
||
}
|
||
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. They only EXIST
|
||
// under PCSX2_RECOMPILER_TESTS (ENABLE_RECOMPILER_TEST_HOOKS=ON) — production
|
||
// configs (build-rocknix) compile eerunner without them, so every reference
|
||
// here must share the gate or the link breaks.
|
||
#ifdef PCSX2_RECOMPILER_TESTS
|
||
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;
|
||
#endif
|
||
|
||
namespace
|
||
{
|
||
struct Cop2Read
|
||
{
|
||
u32 ee_pc, op, fs;
|
||
u32 lanes[4];
|
||
u32 tpc, q, mac, status, clip;
|
||
};
|
||
std::vector<Cop2Read> s_cop2_sink;
|
||
|
||
#ifdef PCSX2_RECOMPILER_TESTS
|
||
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;
|
||
}
|
||
#endif // PCSX2_RECOMPILER_TESTS
|
||
} // 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<Cop2Read>& a, const std::vector<Cop2Read>& 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()
|
||
{
|
||
#ifndef PCSX2_RECOMPILER_TESTS
|
||
Console.Error("--vu0diff needs the COP2 capture hooks: rebuild with ENABLE_RECOMPILER_TEST_HOOKS=ON.");
|
||
return EXIT_FAILURE;
|
||
#endif
|
||
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<Cop2Read>& 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();
|
||
#ifdef PCSX2_RECOMPILER_TESTS
|
||
g_cop2ReadHook = &Cop2ReadCapture;
|
||
g_cop2StateHook = &Cop2StateCapture;
|
||
AdvanceFrames(1);
|
||
g_cop2ReadHook = nullptr;
|
||
g_cop2StateHook = nullptr;
|
||
#endif
|
||
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<Cop2Read> 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;
|
||
}
|
||
|
||
// --statereport: load the savestate and print a legible, field-level decode of every
|
||
// serialized timebase and transfer-engine state, then exit without running a frame.
|
||
// Built for A/B-diffing a poisoned state against a clean one:
|
||
// diff <(eerunner --statereport --savestate A ...) <(eerunner --statereport --savestate B ...)
|
||
// A stuck-timer bug lives in the RELATIONSHIP between clocks that normally advance in
|
||
// lockstep (cycle vs lastCOP0Cycle, rcnt startCycle bases, EE vs IOP, vsync phase), so
|
||
// every line prints the stored baseline AND the value derived the way the guest would
|
||
// read it. Decoding goes through the emulator's own thaw path (VMManager::LoadState),
|
||
// so this report can never drift from the on-disk format.
|
||
static int RunStateReport()
|
||
{
|
||
Error error;
|
||
if (!VMManager::LoadState(s_savestate_path.c_str(), &error))
|
||
{
|
||
Console.ErrorFmt("statereport: load failed: {}", error.GetDescription());
|
||
return EXIT_FAILURE;
|
||
}
|
||
|
||
constexpr double EE_HZ = 294912000.0;
|
||
constexpr double IOP_HZ = 36864000.0;
|
||
const u64 cyc = cpuRegs.cycle;
|
||
|
||
Console.WriteLn(fmt::format("== STATE REPORT: {} ==", Path::GetFileName(s_savestate_path)));
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"[ee] pc={:#010x} cycle={} ({:.6f}s) nextEvent=+{} lastEvent={} interrupt={:#010x} branch={}",
|
||
cpuRegs.pc, cyc, cyc / EE_HZ, (s64)(cpuRegs.nextEventCycle - cyc),
|
||
(s64)(cpuRegs.lastEventCycle - cyc), cpuRegs.interrupt, cpuRegs.branch));
|
||
|
||
// The guest-visible COP0 Count is CP0.Count + (cycle - lastCOP0Cycle) at the moment
|
||
// of the MFC0 (COP0.cpp) — a stale lastCOP0Cycle in a state file therefore warps the
|
||
// game's realtime clock even though Count itself looks plausible.
|
||
Console.WriteLn(fmt::format(
|
||
"[ee.cop0] Count={} lastCOP0Cycle={} (lag={}) mfc0_now={} Compare={} Status={:#010x} Cause={:#010x}",
|
||
(u32)cpuRegs.CP0.n.Count, cpuRegs.lastCOP0Cycle, (s64)(cyc - cpuRegs.lastCOP0Cycle),
|
||
(u32)(cpuRegs.CP0.n.Count + (u32)(cyc - cpuRegs.lastCOP0Cycle)),
|
||
(u32)cpuRegs.CP0.n.Compare, cpuRegs.CP0.n.Status.val, cpuRegs.CP0.n.Cause));
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"[ee.intc] STAT={:#010x} MASK={:#010x} pending&unmasked={:#010x} | DMAC STAT={:#06x} MASK={:#06x}",
|
||
psHu32(INTC_STAT), psHu32(INTC_MASK), psHu32(INTC_STAT) & psHu32(INTC_MASK),
|
||
psHu16(0xe012), psHu16(0xe010)));
|
||
|
||
for (int n = 0; n < 32; n++)
|
||
{
|
||
if (!(cpuRegs.interrupt & (1u << n)))
|
||
continue;
|
||
const u64 deadline = cpuRegs.sCycle[n] + cpuRegs.eCycle[n];
|
||
Console.WriteLn(fmt::format("[ee.int{:02d}] sCycle={} eCycle={} deadline={} ({})",
|
||
n, cpuRegs.sCycle[n], cpuRegs.eCycle[n], deadline,
|
||
(s64)(deadline - cyc) <= 0 ? std::string("DUE") : fmt::format("+{}", (s64)(deadline - cyc))));
|
||
}
|
||
|
||
// EE hardware counters: eff~ is derived the way rcntRcount derives it, i.e. what the
|
||
// game would read back, not just the stored baseline.
|
||
for (int i = 0; i < 4; i++)
|
||
{
|
||
const Counter& c = counters[i];
|
||
const u64 age = cyc - c.startCycle;
|
||
const u64 eff = c.mode.IsCounting ? (c.count + (c.rate ? (age / c.rate) : 0)) : c.count;
|
||
Console.WriteLn(fmt::format(
|
||
"[rcnt{}] mode={:#06x} src={} counting={} gate={}/{} count={} target={} hold={} rate={} startCycle={} age={} eff~{} ({:.6f}s)",
|
||
i, c.modeval & 0xffff, (u32)c.mode.ClockSource, (u32)c.mode.IsCounting,
|
||
(u32)c.mode.EnableGate, (u32)c.mode.GateMode,
|
||
c.count, c.target, c.hold, c.rate, c.startCycle, age, eff,
|
||
(eff * (double)c.rate) / EE_HZ));
|
||
}
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"[vsync] mode={} startCycle={} (age={}) delta={} | hsync mode={} startCycle={} (age={}) delta={}",
|
||
vsyncCounter.Mode, vsyncCounter.startCycle, (s64)(cyc - vsyncCounter.startCycle), vsyncCounter.deltaCycles,
|
||
hsyncCounter.Mode, hsyncCounter.startCycle, (s64)(cyc - hsyncCounter.startCycle), hsyncCounter.deltaCycles));
|
||
Console.WriteLn(fmt::format(
|
||
"[sched] nextDeltaCounter={} nextStartCounter={} fires_at={} ({})",
|
||
nextDeltaCounter, nextStartCounter, nextStartCounter + (u64)nextDeltaCounter,
|
||
((s64)(nextStartCounter + (u64)nextDeltaCounter - cyc) <= 0) ? "DUE" : "future"));
|
||
|
||
// EE<->IOP: the two cores nominally hold cycle_EE == 8 * cycle_IOP; skew is the drift
|
||
// a poisoned state would carry between the two serialized clock domains.
|
||
Console.WriteLn(fmt::format(
|
||
"[ee-iop] EEsCycle={} EEoCycle={} iop.cycle={} ee/8={} skew={} | ee={:.6f}s iop={:.6f}s d={:+.6f}s",
|
||
EEsCycle, EEoCycle, psxRegs.cycle, cyc / 8, (s64)(cyc / 8 - psxRegs.cycle),
|
||
cyc / EE_HZ, psxRegs.cycle / IOP_HZ, cyc / EE_HZ - psxRegs.cycle / IOP_HZ));
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"[iop] pc={:#010x} cycle={} interrupt={:#010x} psxNextDelta={} psxNextStart={}",
|
||
psxRegs.pc, psxRegs.cycle, psxRegs.interrupt, psxNextDeltaCounter, psxNextStartCounter));
|
||
|
||
for (int i = 0; i < NUM_COUNTERS; i++)
|
||
{
|
||
const psxCounter& c = psxCounters[i];
|
||
const u64 age = psxRegs.cycle - c.startCycle;
|
||
const u64 eff = c.count + (c.rate ? (age / c.rate) : 0);
|
||
Console.WriteLn(fmt::format(
|
||
"[iop.rc{}] mode={:#06x} count={} target={} rate={} startCycle={} age={} delta={} eff~{}",
|
||
i, c.mode.modeval & 0xffff, c.count, c.target, c.rate, c.startCycle, age, c.deltaCycles, eff));
|
||
}
|
||
|
||
// RTC bytes are stored raw (BCD-ish, cdvd.cpp itob/btoi) — print hex, don't interpret.
|
||
Console.WriteLn(fmt::format(
|
||
"[cdvd] rtc={:02x}-{:02x}-{:02x} {:02x}:{:02x}:{:02x} status={:#04x}",
|
||
cdvd.RTC.year, cdvd.RTC.month, cdvd.RTC.day,
|
||
cdvd.RTC.hour, cdvd.RTC.minute, cdvd.RTC.second, cdvd.RTC.status));
|
||
|
||
for (int i = 0; i < 3; i++)
|
||
{
|
||
Gif_Path& p = gifUnit.gifPath[i];
|
||
Console.WriteLn(fmt::format(
|
||
"[gif.p{}] state={} curSize={} curOffset={} readAmount={} done={}",
|
||
i + 1, (int)p.state, p.curSize, p.curOffset, p.readAmount.load(), p.isDone() ? 1 : 0));
|
||
}
|
||
Console.WriteLn(fmt::format(
|
||
"[gif] GIF_STAT={:#010x} signal.queued={} finish fired={} pending={}",
|
||
psHu32(0x3020), gifUnit.gsSIGNAL.queued ? 1 : 0,
|
||
gifUnit.gsFINISH.gsFINISHFired ? 1 : 0, gifUnit.gsFINISH.gsFINISHPending ? 1 : 0));
|
||
|
||
for (int i = 0; i < 2; i++)
|
||
{
|
||
const vifStruct& v = (i == 0) ? vif0 : vif1;
|
||
Console.WriteLn(fmt::format(
|
||
"[vif{}] cmd={:#04x} done={} waitforvu={} inprogress={} dmamode={} irq={} queued_program={} queued_pc={}",
|
||
i, (u32)v.cmd & 0xff, v.done ? 1 : 0, v.waitforvu ? 1 : 0, v.inprogress, v.dmamode,
|
||
v.irq, v.queued_program ? 1 : 0, v.queued_pc));
|
||
}
|
||
|
||
static constexpr struct { const char* name; u32 base; } kDmaCh[] = {
|
||
{"vif0", 0x8000}, {"vif1", 0x9000}, {"gif", 0xa000}, {"ipu0", 0xb000}, {"ipu1", 0xb400},
|
||
{"sif0", 0xc000}, {"sif1", 0xc400}, {"sif2", 0xc800}, {"spr0", 0xd000}, {"spr1", 0xd400}};
|
||
for (const auto& ch : kDmaCh)
|
||
{
|
||
Console.WriteLn(fmt::format(
|
||
"[dma.{:<4}] chcr={:#010x} madr={:#010x} qwc={} tadr={:#010x}",
|
||
ch.name, psHu32(ch.base), psHu32(ch.base + 0x10),
|
||
psHu32(ch.base + 0x20) & 0xffff, psHu32(ch.base + 0x30)));
|
||
}
|
||
|
||
Console.WriteLn(fmt::format(
|
||
"[mtvu] vuCycles=[{},{},{},{}] idx={} interrupts={:#x} gsSignal={:#x} gsLabel={:#x}",
|
||
vu1Thread.vuCycles[0].load(), vu1Thread.vuCycles[1].load(),
|
||
vu1Thread.vuCycles[2].load(), vu1Thread.vuCycles[3].load(),
|
||
vu1Thread.vuCycleIdx, vu1Thread.mtvuInterrupts.load(),
|
||
vu1Thread.gsSignal.load(), vu1Thread.gsLabel.load()));
|
||
|
||
return EXIT_SUCCESS;
|
||
}
|
||
|
||
static std::atomic<uint32_t> s_liverun_frame{0};
|
||
static std::atomic<bool> 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));
|
||
}
|
||
}
|
||
|
||
#ifdef __linux__
|
||
// Per-thread hardware counters over the liverun frame window (attached after
|
||
// savestate load, read at shutdown next to @THREADCPU@). The @THREADPERF@
|
||
// "CPU Thread" line gives codegen_ab.sh an EE-thread-SCOPED deterministic
|
||
// metric: whole-process `perf stat` dilutes an EE-only codegen delta by the
|
||
// GS/MTVU/worker threads' share (~50% measured on SotC), and the time-based
|
||
// @THREADCPU@ line is clock-sensitive. Counters attach per-tid, so boot and
|
||
// savestate-load work is excluded (JIT compile of the scene's blocks is not —
|
||
// it runs on the EE thread inside the window, identically for base/new).
|
||
struct ThreadPerfCounter
|
||
{
|
||
std::string comm;
|
||
int fd_ins = -1;
|
||
int fd_cyc = -1;
|
||
bool user_only = false;
|
||
};
|
||
static std::vector<ThreadPerfCounter> s_thread_perf;
|
||
|
||
static int OpenThreadPerfCounter(pid_t tid, u64 config, bool* user_only)
|
||
{
|
||
perf_event_attr attr = {};
|
||
attr.type = PERF_TYPE_HARDWARE;
|
||
attr.size = sizeof(attr);
|
||
attr.config = config;
|
||
attr.exclude_hv = 1;
|
||
int fd = static_cast<int>(syscall(SYS_perf_event_open, &attr, tid, -1, -1, 0));
|
||
if (fd < 0 && errno == EACCES)
|
||
{
|
||
// perf_event_paranoid >= 2 (unprivileged dev box): user-space-only counting.
|
||
attr.exclude_kernel = 1;
|
||
fd = static_cast<int>(syscall(SYS_perf_event_open, &attr, tid, -1, -1, 0));
|
||
if (fd >= 0)
|
||
*user_only = true;
|
||
}
|
||
return fd;
|
||
}
|
||
|
||
static void StartThreadPerfCounters()
|
||
{
|
||
for (const auto& entry : std::filesystem::directory_iterator("/proc/self/task"))
|
||
{
|
||
const pid_t tid = static_cast<pid_t>(std::strtol(entry.path().filename().c_str(), nullptr, 10));
|
||
std::ifstream comm_file(entry.path() / "comm");
|
||
std::string comm;
|
||
std::getline(comm_file, comm);
|
||
ThreadPerfCounter c;
|
||
c.comm = comm.empty() ? entry.path().filename().string() : comm;
|
||
c.fd_ins = OpenThreadPerfCounter(tid, PERF_COUNT_HW_INSTRUCTIONS, &c.user_only);
|
||
c.fd_cyc = OpenThreadPerfCounter(tid, PERF_COUNT_HW_CPU_CYCLES, &c.user_only);
|
||
if (c.fd_ins >= 0 || c.fd_cyc >= 0)
|
||
s_thread_perf.push_back(std::move(c));
|
||
}
|
||
if (s_thread_perf.empty())
|
||
Console.Warning("liverun: perf_event_open failed for all threads; no @THREADPERF@ lines (perf_event_paranoid?)");
|
||
}
|
||
|
||
static void ReportThreadPerfCounters()
|
||
{
|
||
for (ThreadPerfCounter& c : s_thread_perf)
|
||
{
|
||
u64 ins = 0, cyc = 0;
|
||
if (c.fd_ins >= 0)
|
||
{
|
||
if (read(c.fd_ins, &ins, sizeof(ins)) != sizeof(ins))
|
||
ins = 0;
|
||
close(c.fd_ins);
|
||
}
|
||
if (c.fd_cyc >= 0)
|
||
{
|
||
if (read(c.fd_cyc, &cyc, sizeof(cyc)) != sizeof(cyc))
|
||
cyc = 0;
|
||
close(c.fd_cyc);
|
||
}
|
||
Console.WriteLn(fmt::format("@THREADPERF@ {}: instructions={} cycles={}{}",
|
||
c.comm, ins, cyc, c.user_only ? " (user-only)" : ""));
|
||
}
|
||
s_thread_perf.clear();
|
||
}
|
||
#endif
|
||
|
||
static int RunLiveRun()
|
||
{
|
||
Error error;
|
||
if (!VMManager::LoadState(s_savestate_path.c_str(), &error))
|
||
{
|
||
Console.ErrorFmt("liverun: load failed: {}", error.GetDescription());
|
||
return EXIT_FAILURE;
|
||
}
|
||
|
||
#ifdef __linux__
|
||
StartThreadPerfCounters();
|
||
#endif
|
||
|
||
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<u32, int> 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<std::pair<u32, int>> 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
|
||
}
|
||
});
|
||
|
||
// EERUNNER_EXITSTORM=<period_us>: fire Cpu->ExitExecution() from a foreign
|
||
// thread at randomized intervals averaging ~period_us, mimicking the Android
|
||
// JNI pause/suspend churn (native-lib pause() calls ExitExecution cross-thread
|
||
// against a running EE). Hunts clock corruption at the delta-pinning seams:
|
||
// recSafeExitExecution writes cpuRegs.nextEventCycle=0 while JIT blocks hold
|
||
// RECCYCLE = cycle - nextEventCycle, so a mid-chain hit skews the next flush.
|
||
std::atomic<bool> storm_stop{false};
|
||
std::thread stormthread;
|
||
u32 storm_period = 0;
|
||
if (const char* e = std::getenv("EERUNNER_EXITSTORM"))
|
||
storm_period = static_cast<u32>(strtoul(e, nullptr, 0));
|
||
if (storm_period)
|
||
{
|
||
Console.WriteLn(fmt::format("LIVERUN: EXITSTORM armed, ~{}us between cross-thread ExitExecution calls", storm_period));
|
||
stormthread = std::thread([&storm_stop, storm_period]() {
|
||
u32 lcg = 0x12345678;
|
||
while (!storm_stop.load(std::memory_order_relaxed))
|
||
{
|
||
lcg = lcg * 1664525u + 1013904223u;
|
||
std::this_thread::sleep_for(std::chrono::microseconds(storm_period / 2 + (lcg % storm_period)));
|
||
if (Cpu)
|
||
Cpu->ExitExecution();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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<u32>(strtoul(e, nullptr, 0));
|
||
auto eeR32 = [](u32 paddr) -> u32 {
|
||
return *reinterpret_cast<const u32*>(reinterpret_cast<const char*>(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);
|
||
|
||
// Arm the GS dump once the scene has settled. Recording stops on its own
|
||
// after s_gsdump_frames vsyncs (GSRenderer counts m_dump_frames down), so
|
||
// the run just has to outlast it — hence the frame-budget check below.
|
||
if (!s_gsdump_path.empty() && (f + 1) == s_gsdump_at)
|
||
{
|
||
Console.WriteLn(fmt::format("GSDUMP: recording {} frame(s) from frame {} to '{}'",
|
||
s_gsdump_frames, s_gsdump_at, s_gsdump_path));
|
||
const std::string path = s_gsdump_path;
|
||
const u32 nframes = s_gsdump_frames;
|
||
MTGS::RunOnGSThread([path, nframes]() { GSQueueSnapshot(path, nframes); });
|
||
}
|
||
|
||
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<u32, int> hist;
|
||
for (int i = 0; i < 2000; i++)
|
||
{
|
||
hist[cpuRegs.pc]++;
|
||
std::this_thread::sleep_for(std::chrono::microseconds(200));
|
||
}
|
||
std::vector<std::pair<u32, int>> 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);
|
||
storm_stop.store(true, std::memory_order_relaxed);
|
||
if (stormthread.joinable())
|
||
stormthread.join();
|
||
watchdog.join();
|
||
|
||
// Per-frame GS stats (averaged over g_perfmon's last 32-frame window): draws, render
|
||
// passes, readbacks (RB), texture copies/uploads. Read cross-thread while the VM is
|
||
// still up — racy u64 reads, cosmetic-only. @GSSTAT@ so scripts can grep it.
|
||
{
|
||
SmallString gs_stats;
|
||
GSgetStats(gs_stats);
|
||
Console.WriteLn(fmt::format("@GSSTAT@ per-frame: {}", gs_stats.view()));
|
||
}
|
||
|
||
#ifdef __linux__
|
||
ReportThreadPerfCounters();
|
||
|
||
// Per-thread CPU seconds (utime+stime) while the VM threads are still alive.
|
||
// Wallclock A/B on the SD865 is too noisy for sub-ms/frame codegen deltas; the
|
||
// per-thread number isolates e.g. GS-thread cost from GPU/sync/scheduler jitter.
|
||
// comm can contain spaces ("CPU Thread") — parse /proc stat around the ')'.
|
||
{
|
||
const long tck = sysconf(_SC_CLK_TCK);
|
||
for (const auto& entry : std::filesystem::directory_iterator("/proc/self/task"))
|
||
{
|
||
std::ifstream stat_file(entry.path() / "stat");
|
||
std::string line;
|
||
if (!std::getline(stat_file, line))
|
||
continue;
|
||
const size_t rp = line.rfind(')');
|
||
if (rp == std::string::npos)
|
||
continue;
|
||
const std::string comm = line.substr(line.find('(') + 1, rp - line.find('(') - 1);
|
||
std::istringstream rest(line.substr(rp + 2));
|
||
std::string field;
|
||
u64 utime = 0, stime = 0;
|
||
for (int i = 1; rest >> field; i++) // field 1 here = stat field 3 (state)
|
||
{
|
||
if (i == 12)
|
||
utime = std::strtoull(field.c_str(), nullptr, 10);
|
||
else if (i == 13)
|
||
{
|
||
stime = std::strtoull(field.c_str(), nullptr, 10);
|
||
break;
|
||
}
|
||
}
|
||
Console.WriteLn(fmt::format("@THREADCPU@ {}: {:.2f} s", comm,
|
||
static_cast<double>(utime + stime) / static_cast<double>(tck)));
|
||
}
|
||
}
|
||
#endif
|
||
|
||
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<int>* ret)
|
||
{
|
||
ret->store(EXIT_FAILURE);
|
||
|
||
if (VMManager::Internal::CPUThreadInitialize())
|
||
{
|
||
// Profiling: set the jitdump output dir before any JIT block compiles (the
|
||
// first compile happens during the first FrameAdvance, well after this). Dir =
|
||
// EmuFolders::Cache so the 100s-of-MB dump avoids /tmp/tmpfs, matching the
|
||
// production rationale in common/Perf.cpp. The ENABLE flag is driven through the
|
||
// normal settings path instead (EmuCore/Profiler EnablePerfDump, set in the
|
||
// harness config) — ApplySettings() below calls LoadSettings() which re-applies
|
||
// Perf::SetJitDumpEnabled(EnablePerfDump), so a manual enable here would just get
|
||
// reset to the config default (false). No-op on non-jitdump builds.
|
||
// EERUNNER_LEGACY_CORE: building against a pre-transplant emu core (ARMSX2
|
||
// master) whose common/Perf.h has no SetJitDumpDir — the dump lands in
|
||
// the default dir there, which is fine for the twin golden side.
|
||
#ifndef EERUNNER_LEGACY_CORE
|
||
if (s_perf_jitdump)
|
||
Perf::SetJitDumpDir(EmuFolders::Cache);
|
||
#endif
|
||
|
||
// 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::TwinDump:
|
||
code = RunTwinDump();
|
||
break;
|
||
|
||
case RunMode::TwinCompare:
|
||
code = RunTwinCompare();
|
||
break;
|
||
|
||
case RunMode::DumpConfig:
|
||
code = RunDumpConfig();
|
||
break;
|
||
|
||
case RunMode::MkState:
|
||
code = RunMkState();
|
||
break;
|
||
|
||
case RunMode::SpeedhackDiff:
|
||
code = RunSpeedhackDiff();
|
||
break;
|
||
|
||
case RunMode::LiveRun:
|
||
code = RunLiveRun();
|
||
break;
|
||
|
||
case RunMode::Disasm:
|
||
code = RunDisasm();
|
||
break;
|
||
|
||
case RunMode::StateReport:
|
||
code = RunStateReport();
|
||
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;
|
||
|
||
// EERUNNER_LEGACY_CORE: pre-transplant cores allocate VM memory inside
|
||
// VMManager::Internal::CPUThreadInitialize (SysMemory::Allocate) instead of
|
||
// exposing a frontend-driven reservation.
|
||
#ifndef EERUNNER_LEGACY_CORE
|
||
SysMemory::ReserveMemory();
|
||
#endif
|
||
|
||
// --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<int> 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<std::string> u8_args;
|
||
u8_args.reserve(static_cast<size_t>(argc));
|
||
for (int i = 0; i < argc; i++)
|
||
u8_args.push_back(StringUtil::WideStringToUTF8String(argv[i]));
|
||
|
||
std::vector<char*> 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
|